Skip to content

Tool-call loop: deepseek-v4-pro collapses into repeated identical tool_call inside its working context window, no client-side circuit breaker #4695

Description

@yiliang114

What happened?

deepseek-v4-pro (via the OpenAI-compatible Bailian / DashScope endpoint) gets stuck in a tool-call loop once the in-context history grows long, inside the model's normal context window on both channels I tested. In a real session of mine (qwen-code v0.17.0), after I asked "what files changed on this branch?", the assistant called git status --short once normally, and from the second round onward every reply was the same shape:

[
  { "text": "Let me summarize the changes clearly.", "thought": true },
  { "functionCall": {
      "name": "run_shell_command",
      "args": { "command": "git status --short", "description": "Git status summary" }
  }}
]

The model even has a kind of self-awareness — it produced All right, let me stop this loop and give a clear summary. and Let me just provide the final summary. in the thought slot — but every "I'm going to stop" line still ships the same tool call, so qwen-code keeps executing it and feeding the result back. There is no client-side circuit breaker, so it ran until I hit ESC.

Cost of this single loop:

Metric Value
Duration before user ESC 10 min 17 s
Consecutive API calls 50
"Let me summarize the changes clearly." repeats 43
Per-round input tokens ~170k
Total input tokens sent 8,947,572
cached_content_token_count 0 — every round re-sent the full prompt

Not an over-limit case. I verified the Bailian endpoint actually serves >400k input tokens for this model without rejecting (prompt_tokens=400,013 on a single test request, normal 200 response, model still recalled the content). The 170k original session and the ~101k public repro below are both comfortably inside the channel's working window. The DashScope /v1/models/deepseek-v4-pro endpoint reports max_tokens=131072 — that limit is a channel-specific surface, the underlying model is being served with a much larger window on the Bailian side. Either way, this is not a "you went over the limit" effect.

Related symptom (no repro / no root cause): #4055.

What did you expect to happen?

  1. The client should detect repeated identical (tool_name, normalized_args) calls within a single turn and break the loop — e.g. after 3 identical calls, force the model to answer in text instead of calling the same tool again.
  2. A hard per-turn cap on tool-call rounds (say, 30), after which qwen-code retries once with tool_choice="none" so the model has to produce a textual reply.
  3. The same ~170k prompt should not get re-sent 50 times in 10 minutes.

Minimum reproduction

I wrote a single-file public repro (no business code, only generic README / src / docs / ls). It only needs DASHSCOPE_API_KEY and the openai SDK.

Trigger recipe (all three are needed; any one alone does not reproduce):

Factor Threshold
Model deepseek-v4-pro (Bailian / DashScope, OpenAI-compatible)
Context size ≥ ~100k tokens — inside the channel's working window, not over-limit
Repeats of thought="Let me summarize" + same tool_call in history ≥ ~20
Sampling temperature 0.3 (0.7 lets randomness rescue a fraction of trials)

Observed reproduction rate (8 trials, prior=20, temp=0.3, ctx≈101k):

Verdict Count
LOOP — model emits the same ls tool call again 5 / 8
OTHER — model calls a different tool (list_directory) but still no text 2 / 8
TEXT — model finally answers in text 1 / 8
Failed-to-respond rate (LOOP + OTHER) 87 %

Negative-control table — important, this is why my first guess ("repeated history alone is enough") was wrong:

Config LOOP rate
~1k system + 0–10 prior repeats 0 / 12
≥120k ctx + 0 prior repeats 0 / 3
≥120k ctx + 3 prior repeats 0 / 3
≥120k ctx + 10 prior repeats 0 / 3
~120k ctx + 20 prior repeats, temp=0.3 3 / 8
~101k ctx + 20 prior repeats, temp=0.3 (public repro) 5 / 8

So this is not "model is broken in OpenAI tool-calling schema", and it is not an over-limit effect (verified — see the >400k test above). It is a long-but-in-window context + self-reinforcing in-context loop effect. Once the model collapses once, the next round's history has one more "previously also called the same tool" example, which pushes the next collapse probability higher. That is the snowball that produced 43 consecutive repeats in the original session.

repro_deepseek_loop.py (single file, ~200 lines, no business code)
#!/usr/bin/env python3
"""
Minimal public reproduction: deepseek-v4-pro infinite tool-call loop
under long context + repeated identical tool history.

Deps: pip install openai
Env:  DASHSCOPE_API_KEY
Run:  python repro_deepseek_loop.py            # 8 trials
      python repro_deepseek_loop.py 20         # 20 trials
"""
import json, os, sys, time, random
from openai import OpenAI

API_KEY = os.environ.get("DASHSCOPE_API_KEY")
if not API_KEY:
    sys.exit("set DASHSCOPE_API_KEY first")

client = OpenAI(api_key=API_KEY,
                base_url="https://dashscope.aliyuncs.com/compatible-mode/v1")
MODEL = "deepseek-v4-pro"

SYSTEM_PROMPT = (
    "You are CodeBot, an interactive CLI coding assistant.\n"
    "Use the provided tools to inspect the user's project and help with software tasks.\n"
    "# Working style\n"
    "- Prefer reading the actual files before answering.\n"
    "- When in doubt, run a command to verify rather than guessing.\n"
    "- Keep responses short and concrete.\n"
    "# Project context\n"
    "- Working directory: /home/dev/sample-project\n"
    "- Stack: a generic Python + Markdown repo.\n"
)
SYSTEM_PROMPT += "\n\n" + ("# extra style guidance line.\n" * 200)

TOOLS = [
    {"type":"function","function":{"name":"run_shell_command",
        "description":"Execute a shell command.",
        "parameters":{"type":"object","properties":{
            "command":{"type":"string"},"description":{"type":"string"}},
            "required":["command"]}}},
    {"type":"function","function":{"name":"read_file",
        "description":"Read a text file.",
        "parameters":{"type":"object","properties":{
            "absolute_path":{"type":"string"}},
            "required":["absolute_path"]}}},
    {"type":"function","function":{"name":"grep_search",
        "description":"Search files by regex.",
        "parameters":{"type":"object","properties":{
            "pattern":{"type":"string"},"path":{"type":"string"}},
            "required":["pattern"]}}},
    {"type":"function","function":{"name":"list_directory",
        "description":"List directory.",
        "parameters":{"type":"object","properties":{
            "path":{"type":"string"}},
            "required":["path"]}}},
]

LS_OUTPUT = ("Command: ls\nOutput:\nREADME.md\nCHANGELOG.md\n"
             "docs/\nsrc/\ntests/\npyproject.toml\n")

FAKE_README = "# Sample Project\n\n" + "\n".join(
    f"- bullet {i}: neutral description." for i in range(60))
FAKE_PY = "import os\n\n" + "\n".join(
    f"def f{i}(x):\n    return x * 2 + {i}\n" for i in range(20))

def fake_tool_pair(idx, kind):
    cid = f"call_pad_{idx}"
    if kind == "read":
        path = random.choice([
            "/home/dev/sample-project/README.md",
            "/home/dev/sample-project/src/utils.py",
            "/home/dev/sample-project/docs/intro.md",
        ])
        a = {"role":"assistant","content":f"Reading {path.split('/')[-1]}.",
             "tool_calls":[{"id":cid,"type":"function","function":{
                 "name":"read_file",
                 "arguments":json.dumps({"absolute_path":path})}}]}
        body = FAKE_README if path.endswith(".md") else FAKE_PY
        r = {"role":"tool","tool_call_id":cid,"content":f"File: {path}\n\n{body}"}
    elif kind == "grep":
        pat = random.choice(["TODO","FIXME","def ","import "])
        a = {"role":"assistant","content":f"Searching {pat}.",
             "tool_calls":[{"id":cid,"type":"function","function":{
                 "name":"grep_search",
                 "arguments":json.dumps({"pattern":pat,"path":"src"})}}]}
        r = {"role":"tool","tool_call_id":cid,
             "content":"\n".join(f"src/m_{i}.py:{10+i}: {pat}" for i in range(30))}
    elif kind == "list":
        path = random.choice(["src","docs","tests","."])
        a = {"role":"assistant","content":f"Listing {path}.",
             "tool_calls":[{"id":cid,"type":"function","function":{
                 "name":"list_directory",
                 "arguments":json.dumps({"path":path})}}]}
        r = {"role":"tool","tool_call_id":cid,
             "content":"\n".join(f"{path}/entry_{i}.py" for i in range(25))}
    else:
        cmd = random.choice(["python -m pytest -q","git log --oneline -5",
                             "wc -l src/*.py"])
        a = {"role":"assistant","content":f"Running: {cmd}",
             "tool_calls":[{"id":cid,"type":"function","function":{
                 "name":"run_shell_command",
                 "arguments":json.dumps({"command":cmd,"description":"check"})}}]}
        r = {"role":"tool","tool_call_id":cid,
             "content":f"Command: {cmd}\nOutput:\n" +
                       "\n".join(f"fake line {i}" for i in range(40))}
    return a, r

def build_history(target_tokens, n_prior):
    msgs = [{"role":"system","content":SYSTEM_PROMPT},
            {"role":"user","content":"Tell me what this project is about."}]
    random.seed(42)
    idx = 0
    while idx < 250:
        a, r = fake_tool_pair(idx, random.choice(["read","grep","list","shell"]))
        msgs += [a, r]; idx += 1
        if sum(len(json.dumps(m)) for m in msgs) // 3 >= target_tokens:
            break
    msgs += [{"role":"user","content":"Got it, thanks."},
             {"role":"assistant","content":"You're welcome."}]
    msgs.append({"role":"user","content":"What files are in the project root?"})
    cid0 = "call_ls_init"
    msgs += [{"role":"assistant","content":"Let me list the project root.",
              "tool_calls":[{"id":cid0,"type":"function","function":{
                  "name":"run_shell_command",
                  "arguments":json.dumps({"command":"ls",
                                          "description":"List project root"})}}]},
             {"role":"tool","tool_call_id":cid0,"content":LS_OUTPUT}]
    for i in range(n_prior):
        cid = f"call_loop_{i}"
        msgs += [{"role":"assistant",
                  "content":"Let me summarize the files clearly.",
                  "tool_calls":[{"id":cid,"type":"function","function":{
                      "name":"run_shell_command",
                      "arguments":json.dumps({"command":"ls",
                                              "description":"Summarize project root"})}}]},
                 {"role":"tool","tool_call_id":cid,"content":LS_OUTPUT}]
    return msgs

def one_turn(messages):
    resp = client.chat.completions.create(
        model=MODEL, messages=messages, tools=TOOLS,
        tool_choice="auto", temperature=0.3, max_tokens=2000)
    msg = resp.choices[0].message
    text = (msg.content or "").strip()
    tcs = [{"name":tc.function.name,"args":tc.function.arguments}
           for tc in (msg.tool_calls or [])]
    return text, tcs, resp.usage.prompt_tokens, resp.usage.completion_tokens

def classify(text, tcs):
    for tc in tcs:
        try:
            args = json.loads(tc["args"])
        except Exception:
            args = {}
        if tc["name"] == "run_shell_command" and args.get("command","").strip() == "ls":
            return "LOOP"
    if tcs:  return "OTHER"
    if text: return "TEXT"
    return "EMPTY"

def main():
    n_trials = int(sys.argv[1]) if len(sys.argv)>1 else 8
    target   = int(sys.argv[2]) if len(sys.argv)>2 else 150000
    prior    = int(sys.argv[3]) if len(sys.argv)>3 else 20
    msgs = build_history(target, prior)
    print(f"# trials={n_trials} target_ctx~{target} prior={prior} "
          f"temp=0.3 model={MODEL}\n")
    counts = {"LOOP":0,"OTHER":0,"TEXT":0,"EMPTY":0,"ERR":0}
    for i in range(n_trials):
        try:
            text, tcs, pt, ct = one_turn(msgs)
            v = classify(text, tcs); counts[v] += 1
            print(f"[t{i+1:>2}] pt={pt:>7} ct={ct:>4}{v:<5} | "
                  f"text={text[:60]!r} | tcs={[(t['name'],t['args'][:50]) for t in tcs]}")
        except Exception as e:
            counts["ERR"] += 1
            print(f"[t{i+1:>2}] ERROR: {e!r}")
        time.sleep(0.5)
    print("\n=== Summary ===")
    for k, v in counts.items():
        print(f"  {k:<5}: {v}/{n_trials}")
    if counts["LOOP"] or counts["OTHER"]:
        pct = (counts["LOOP"] + counts["OTHER"]) * 100 // n_trials
        print(f"\n→ Failed-to-respond rate (LOOP+OTHER): {pct}%")

if __name__ == "__main__":
    main()

Suggested fix direction

Two cheap client-side defenses that would have caught this loop on round 3 instead of round 43:

  1. Per-turn tool-call dedupe. Track (tool_name, normalized_args) within the current turn. After ≥3 consecutive identical calls, intercept the next one and inject a system message: You already ran the same tool 3 times with identical arguments. Reply to the user in text — do not call this tool again. This breaks the self-reinforcing in-context loop early.
  2. Hard cap on tool-call rounds per turn. Something like 30. When hit, retry once with tool_choice="none" so the model has to fall back to a textual reply.

Both are model-agnostic — even if DeepSeek upstream tightens the schema later, this defense is worth having for every model in the OpenAI-compatible tool-calling path.

A third (server-side, not in this repo): the Bailian channel reports cached_content_token_count=0 for every round, so 99% of the 8.9M input tokens this loop burned were the same prefix being re-sent. Even if the loop bug stays open, enabling prompt cache would significantly limit the blast radius.

Client information

Client Information
qwen-code: 0.17.0
platform:  macOS (Darwin 24.x)
model:     bailian/deepseek-v4-pro
auth_type: openai (OpenAI-compatible)

Login information

OpenAI-compatible provider, model bailian/deepseek-v4-pro (same backend as dashscope/deepseek-v4-pro used in the public repro above).

Anything else we need to know?

  • Context-window check (so the over-limit theory is ruled out up front): I sent a single test request to bailian/deepseek-v4-pro with prompt_tokens=400,013 and got a normal 200 back with the right content recall. The DashScope /v1/models/deepseek-v4-pro endpoint advertises max_tokens=131072, but that's a per-channel surface — the model on the Bailian side accepts much more. The 170k of the original session and the ~101k of the public repro are both inside the working window.
  • Every round shows cached_content_token_count=0 on this channel, which is what turned the 50-round loop into 8.9M billed input tokens. Even without the loop fix, enabling prompt cache would significantly cut the blast radius.
  • This is plausibly the same underlying bug as 一个非常非常简单的问题/要求,qc 循环往复在思考,自循环了 10 分钟还没答复 #4055 (long stretch of self-looping on a simple ask), but 一个非常非常简单的问题/要求,qc 循环往复在思考,自循环了 10 分钟还没答复 #4055 has no repro or root cause attached. Happy to cross-link if a maintainer prefers consolidating.

Metadata

Metadata

Assignees

Labels

category/toolsTool integration and executionmodel/long-contextpriority/P2Medium - Moderately impactful, noticeable problemscope/memoryMemory and context managementtype/bugSomething isn't working as expected

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions