客户端卡死在 tool-use 上——vLLM 工具调用静默截断排查记

十月 14, 2026 [AI] #llm #opencode #调试

现象:一方说卡死,三方都说没错

线上的组合是 gpu 服务器上的 vLLM(dev 版 0.26.1rc1.dev1045)跑 Qwen3.8-27B-FP8(TP=2 + DFlash2 投机解码),agent 参数:

--enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser qwen3

客户端是 opencode。现象只有一句话:客户端 hang 在 tool-use 的返回上。

先看服务端,一片干净:

客户端也不报错:界面转圈,一串重复的工具调用,永远不出结果。

这类"三方都不报错"的故障最麻烦——vLLM 认为自己给了 tool_calls,opencode 认为自己在让模型重试,模型认为自己马上就要写完了。本文记录完整的排查链路、源码级根因,以及一套可复用的排查工具箱。

第一步:先搞清楚服务端到底存了什么

vLLM 是 nohup 裸进程(无 systemd/journald),日志就是启动脚本重定向的那个文件:

# gpu 机上(注意启动用户,日志在它的 $HOME 下)
ls -lt /home/smyl/logs/vllm-8000-*.log

# 关键检索:默认日志里有没有请求/响应?
grep -c '"messages"'        vllm-8000-*.log   # 0
grep -c '"max_tokens"'      vllm-8000-*.log   # 0
grep -c '"tools"'           vllm-8000-*.log   # 0

结论:默认配置下,vLLM 日志只有 access log + 引擎统计,不含任何请求体和生成内容。想让它存,得重启并加:

VLLM_LOGGING_LEVEL=DEBUG      # --enable-log-requests 依赖它,默认 INFO
--enable-log-requests         # 记录请求
--enable-log-outputs          # 默认 False,记录生成文本
--max-log-len <n>             # 控制日志里 prompt 截断长度

生产实例不方便重启,所以这次全程用抓包代替(后文有命令)。这是个重要前提:这类问题不要指望从 vLLM 日志里找到答案。

第二步:客户端能看什么

opencode 的日志(~/.local/share/opencode/log/opencode.log)在 DEBUG 级别也不含请求体,连 max_tokens、finish_reason 都没有;故障发生时一条 WARN/ERROR 都不会有。

真正有用的客户端记录是会话数据:

opencode session list
opencode session export <sessionID> > s.json

# 或者直接查 DB(快得多)
db=~/.local/share/opencode/opencode.db
sqlite3 -header -column "$db" "
  select id, type,
         json_extract(data,'\$.finish')           as finish,
         json_extract(data,'\$.tokens.output')    as out_tok,
         json_extract(data,'\$.tokens.reasoning') as rea_tok,
         json_extract(data,'\$.outcome')          as outcome
  from session_message where session_id='ses_xxx' order by seq;"

一眼就能看到异常形态:finish=tool-calls 反复出现、每轮 output token 极少、工具的 state.status=error。这就是"热循环"的指纹。

第三步:稳定复现

不猜,直接造条件。opencode 侧用项目级配置(不污染全局)把输出预算压小,并关掉思考让 tool call 立刻开始:

// <scratch>/opencode.jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "model": "vllmcut/qwen3.8-27b-fp8",
  "providers": {
    "vllmcut": {
      "package": "aisdk:@ai-sdk/openai-compatible",
      "settings": { "baseURL": "http://<gpu>:8000/v1" },
      "models": {
        "qwen3.8-27b-fp8": {
          "limit": { "context": 200000, "output": 64 },
          "body": { "chat_template_kwargs": { "enable_thinking": false } }
        }
      }
    }
  }
}

然后让它"用 write 工具创建一个 20 行的文件"(长参数,必截断)。实测(每档跑 40 秒左右):

limit.outputassistant 轮数finish 分布错误类型
16125125× tool-calls全部 input={}
649(后自停)8× tool-calls + 1× lengthtool.input-json ×6、tool.execution ×2
1282322× tool-callstool.execution ×19、tool.input-json ×3
1921615× tool-callstool.execution ×11、tool.input-json ×4

交互式 TUI 里跑同一场景(跑约三分半后手动打断)的最终账:

assistant 轮数: 73      finish: {tool-calls: 72, error: 1}
错误类型:        tool.execution ×67(缺键)、tool.input-json ×5(未闭合)
工具执行次数:    0      (executed=False ×72)
demo.txt:        始终未创建
累计 token:      input 714,754 / output 1,267

73 次工具调用、0 次执行、71 万输入 token、零产出 —— 界面上就是"卡死"。

这里要先澄清一个容易写错(我就写错过)的表述:工具不执行,但 opencode 会重试。参数校验没过,opencode 不会真正调用工具(executed=false、文件不落盘),但它会把校验错误当 tool result 回灌给模型,让模型重新生成参数——于是循环。

第四步:根因,一层一层剥

4.1 模型输出的根本不是 JSON

用 /v1/completions(它不打 chat template、不跑 tool parser)+ 本地 apply_chat_template 渲染出与 chat 接口完全一致的 prompt,直接拿模型裸输出:

'<tool_call>\n<function=write>\n<parameter=path>\ndemo.txt\n</parameter>\n<parameter=content>\n1. Lorem ipsum'
# finish_reason=length(真实!)

两个关键事实:

  1. 模型吐的是 XML <parameter=…> 块(Qwen3.8 风格),不是 JSON;
  2. 截断发生在 <parameter=content> 内部——content 这个块从未闭合,裸生成层的 finish_reason 如实报 length。

4.2 vLLM 把它重建成"合法 JSON"

qwen3_coder 在这个版本里指向 Qwen3EngineToolParser,真正的转换在 vllm/parser/qwen3.py:

def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
    params: dict[str, object] = {}

    for match in _PARAM_RE.finditer(raw_args):        # 已闭合的 <parameter=x>v</parameter>
        params[name] = _trim_wrapping_newlines(value)

    if partial:
        remaining = _PARAM_RE.sub("", raw_args)
        m = _PARTIAL_PARAM_RE.search(remaining)       # 最后那个没闭合的块
        if m and m.group(1):
            params[m.group(1)] = _trim_wrapping_newlines(m.group(2))

    return json.dumps(params, ensure_ascii=False)     # ← 重新序列化,括号必然配平

所以客户端拿到的 arguments 是 vLLM 用 json.dumps(params) 现造的:

partial 开关决定两条路的差别(实测,同一 prompt、temperature=0):

路径partial未闭合的 <parameter=content>客户端收到
非流式False整个丢掉{"path": "demo.txt"}(合法但缺键)
流式True收进来原始片断流式下发,收尾补 "};截断点不巧时真的未闭合
[非流式] mt=20/24/30/40  finish=tool_calls  args='{"path": "demo.txt"}'   ← 半截 content 被静默丢弃
[流式]   mt=24           finish=tool_calls  拼接='{"path": "demo.txt"}'
         最后 3 个 delta = ['{"path": "', 'demo.txt', '"}']               ← 闭合是最后一片补的
[流式]   mt=40           finish=tool_calls  拼接='{"path": "demo.txt", "content": "1. Lorem ... \n2'   ← 真没闭合

一个值写半截时(一个 <parameter> 块都没闭合)就得到 {}。

4.3 finish_reason 被改写

对照实验:没有 <tool_call> 标签的截断如实报 length;一旦输出里出现了 <tool_call>,截断就被吞掉、统一报 tool_calls。

max_tokens=64  finish=length      tool_calls=0     ← 无 tool_call:截断如实上报
max_tokens=24  finish=tool_calls  args='{"path"...}'  ← 有 tool_call:截断信息丢失

这是整条链里最坑的一环:finish_reason=tool_calls 在这条通道上不再意味着"成功产出工具调用"。

4.4 客户端两种错误,同一个循环

opencode 对工具失败有两种处理,都不执行工具、都回灌重试:

服务端给的 argumentserror.type界面表现
未闭合 JSONtool.input-json:"Tool call arguments were malformed JSON and were not executed. Retry with valid JSON."连错误文字都不显示,纯转圈
合法 JSON 但缺必填键tool.execution:"Invalid arguments for tool …: Missing key"TUI 可展开显示错误

再叠加上一节的服务端行为,闭环形成:

输出预算被截断
   ↓  裸生成层 finish_reason=length(真实)
vLLM 转换器收「已闭合的参数块」→ json.dumps() → 语法合法的 JSON
   ↓  finish_reason 改报 tool_calls(信息丢失)
opencode 参数校验失败 → 不执行工具 → 错误回灌 → "请重试"
   ↓
模型再写 → 再截断 → 死循环(只有 transport error / 模型吐出非工具内容 / 人工打断能停)

值得注意的是它不是必然死循环:limit.output=64 那轮 8 次失败后模型吐出一段纯文本(length),opencode 就停了。这解释了线上"有时卡一会儿、有时卡到天荒地老"的随机感——取决于模型第 N 次是否产出非工具内容。

排查工具箱(这次实战验证过的)

① 抓包看客户端到底发了什么、服务端到底回了什么(HTTP 明文,最可靠):

# gpu 机上(需要免密 sudo 的用户)
sudo tcpdump -i any -s0 -A "tcp port 8000 and host <client-ip>" > /tmp/cap.txt

# 分析。注意 opencode 新版发的是 max_completion_tokens,两个都要抓
grep -oE '"max_(completion_)?tokens":[0-9]+' /tmp/cap.txt | sort | uniq -c
grep -oE '"finish_reason": ?"[a-z_]+"' /tmp/cap.txt | sort | uniq -c
# 拿完整 arguments 片段(要允许转义,否则会被 \" 截断——我踩过)
grep -oE '"arguments":"([^"\\]|\\.)*"' /tmp/cap.txt | sort | uniq -c

坑:别在 ssh 里 & 后台跑 tcpdump,sshd 会随会话把它收掉;要 tmux 或前台。

② /v1/completions 拿模型裸输出(唯一不经过 tool parser 的推理入口):

from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(MODEL_DIR, trust_remote_code=True)
prompt = tok.apply_chat_template(msgs, tools=tools, add_generation_prompt=True,
                                 tokenize=False, enable_thinking=False)
# POST /v1/completions, prompt=上面字符串, max_tokens=...

顺带说明接口定位:/v1/completions 是 OpenAI 第一代补全接口(2020,GPT-3 时代),不是调试接口,在 vLLM 里是一等公民;/v1/chat/completions 是 2023 年,/v1/responses 是 2025 年。用它不是因为它旧,而是因为只有它"不打模板、不跑 parser"。

③ /v1/chat/completions/render + /detokenize 拿模型实际输入(vLLM 专有,只渲染不推理):

TIDS=$(curl -s localhost:8000/v1/chat/completions/render -H 'Content-Type: application/json' \
  -d '{"model":"...","messages":[...],"chat_template_kwargs":{"enable_thinking":false}}' \
  | python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["token_ids"]))')
curl -s localhost:8000/detokenize -H 'Content-Type: application/json' \
  -d "{\"model\":\"...\",\"tokens\":$TIDS}"
# → '<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n'

带不带 tools、chat_template_kwargs 渲染结果完全不同——排查"客户端模板渲染对不对"的正路。正常请求加 "return_token_ids": true 也能拿到 prompt_token_ids。

④ 会话数据判定(见第二步的 SQL),命中下面三条就是本类故障:

修复与建议

总结

这次排查最有价值的不是结论,而是那个反直觉的事实:"卡死"的每一方都在按设计工作。vLLM 的转换器确实尽力了(能收的参数都收了、JSON 还给补闭合了),opencode 的校验确实尽责了(不合法就不执行、还告诉模型哪里错),模型也确实在一次次重写。设计上的缝隙叠在一起:转换器不报"不完整"、finish_reason 丢了 length、客户端错误回灌没有熔断——就成了只有用户能看见的"hang"。

排查这类"无错误现场"的问题,思路是把三方的话都对齐:抓包看服务端实际发了什么、会话数据看客户端实际收到了什么、/v1/completions 看模型实际说了什么。三个"实际"对上的那一刻,根因就出来了。