客户端卡死在 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 的返回上。
先看服务端,一片干净:
- 两天、1190 次
POST /v1/chat/completions,全部 200 OK - 日志里没有任何 ERROR,唯一能 grep 到的 "error" 都是启动期的无害 warning
Running: N reqs的 token 数一直在涨,说明引擎在正常干活
客户端也不报错:界面转圈,一串重复的工具调用,永远不出结果。
这类"三方都不报错"的故障最麻烦——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.output | assistant 轮数 | finish 分布 | 错误类型 |
|---|---|---|---|
| 16 | 125 | 125× tool-calls | 全部 input={} |
| 64 | 9(后自停) | 8× tool-calls + 1× length | tool.input-json ×6、tool.execution ×2 |
| 128 | 23 | 22× tool-calls | tool.execution ×19、tool.input-json ×3 |
| 192 | 16 | 15× tool-calls | tool.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(真实!)
两个关键事实:
- 模型吐的是 XML
<parameter=…>块(Qwen3.8 风格),不是 JSON; - 截断发生在
<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 对工具失败有两种处理,都不执行工具、都回灌重试:
| 服务端给的 arguments | error.type | 界面表现 |
|---|---|---|
| 未闭合 JSON | tool.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),命中下面三条就是本类故障:
finish=tool-calls反复出现(几十次)- 工具
state.status=error、executed=false - 每轮 output token 极少(贴着输出上限)
修复与建议
- 客户端把输出预算给足:
limit.output不只cover正文,reasoning 也吃同一份max_tokens。思考把预算吃干时是同一种截断,只是连<tool_call>都没开始。 - 别信 chat+tools 通道的
finish_reason:tool_calls只说明"输出里出现过 tool_call 标签",截断信息已被吞掉。要真相就走/v1/completions或抓包。 - schema 校验是最后一道防线:服务端不会报错(转换器永远"成功"),日志不会记(默认不存请求/响应),只有客户端的必填键校验能发现
"content": Missing key。判定逻辑应放在校验之后,而不是 finish_reason 之后。 - 给重试加上限:opencode 这类"错误回灌 + 要求重试"的策略,在预算不足时会退化成 ~1 req/s 的热循环(实测 71 万 input token 零产出)。给"同类工具错误连续 N 次"设熔断,比依赖模型自己吐出非工具内容可靠得多。
总结
这次排查最有价值的不是结论,而是那个反直觉的事实:"卡死"的每一方都在按设计工作。vLLM 的转换器确实尽力了(能收的参数都收了、JSON 还给补闭合了),opencode 的校验确实尽责了(不合法就不执行、还告诉模型哪里错),模型也确实在一次次重写。设计上的缝隙叠在一起:转换器不报"不完整"、finish_reason 丢了 length、客户端错误回灌没有熔断——就成了只有用户能看见的"hang"。
排查这类"无错误现场"的问题,思路是把三方的话都对齐:抓包看服务端实际发了什么、会话数据看客户端实际收到了什么、/v1/completions 看模型实际说了什么。三个"实际"对上的那一刻,根因就出来了。