用一个本机反向代理,修复 Claude Code 偶发的工具调用退化
本文介绍一个开源小工具 claude-proxy,以及它想解决的问题。项目用 Go 写成,零第三方依赖,单文件二进制。
现象
用 Claude Code CLI 干活时,偶尔会撞到这样的画面:
1 | court |
模型明明在思考、也确实想调工具(跑个命令、问你一个选择题),可终端上冒出来的却是一个叫 court 的“工具”,紧跟一行 Invalid tool parameters。工具没执行,会话卡在那里。
这不是你用错了,也不是模型“不会”,而是流式响应在传输/采样环节把结构化工具调用弄坏了。它是间歇性的:同样的对话重开一次可能就好了,所以很难稳定复现,也很难排查。
根因:两类“工具调用退化”
Claude 的 API 用 SSE(Server-Sent Events)流式返回。一次助手回复由若干 content_block 组成,正常情况下工具调用是一个结构化的 tool_use block:
1 | {"type":"content_block_start","index":1, |
退化会以两种形态破坏它。
第一类:工具调用掉进文本里
本该是结构化 tool_use 的调用,被序列化成了普通文本 block,长这样:
1 | count |
前缀在 count / court 之间摇摆、独立成行——这其实是模型内部工具调用格式的“毛坯”泄漏到了文本通道。CLI 拿到的是一段 text,不会去执行,于是工具静默失效。
还有一种更隐蔽的变体(也是本文开头那张截图的真凶):模型把坏前缀 court 单独吐成一个 text block,而真正的 tool_use 结构完整地跟在它后面:
1 | index=0 text block = "……正常叙述。\n\ncourt" ← 尾部残留一个孤儿 court |
CLI 把 text 结尾那个孤零零的 court 当成了一次工具调用意图去解析,没有合法参数 → 报 Invalid tool parameters;而它上面的正常叙述、下面的真工具调用其实都是好的。
一个值得记下来的排查经验:
Invalid tool parameters这行字不在任何上游响应里——它是 CLI 在本地收到响应后自己判定、自己渲染出来的。所以问题不在“模型说了什么”,而在“CLI 收到了什么让它这么判定”。
第二类:工具参数被编码坏
tool_use 结构是完整的,但 input 里某个本该是数组/对象的字段被双重编码成了 JSON 字符串。比如 AskUserQuestion 的 questions 字段,本该是数组,却变成一个字符串:
1 | {"questions": "[{\"question\": ...}]"} ← 整个数组被塞进字符串 |
有时内层还夹着被污染的 \u 转义(如 \u7 ee7 中间多了个空格),JSON 直接就不合法了。CLI 解析参数失败,工具同样跑不起来。
思路:在中间插一个只做修复的代理
这两类退化都发生在流式传输的内容层面,与网络、鉴权无关。那就有一个干净的切入点:在 Claude Code 和真实上游之间插一个本机反向代理,只做一件事——拦截流式 SSE,把退化的调用重组回合法结构,其余原样转发。
1 | Claude Code ──► claude-proxy (127.0.0.1) ──► 真实上游 |
整个设计围绕一条铁律:
任何自身异常,一律 fail-open——原样透传,绝不破坏正常请求。
代理的价值是“偶尔救一把”,代价绝不能是“把本来好的请求搞坏”。所以每一处修复逻辑外面都套着兜底:解析失败、panic、流中断……统统回退到把缓冲的原始事件原样补发出去。宁可漏救一次,也不误伤一次。
修复怎么做
代理逐个处理 SSE 事件,维护一个小状态机:
对第一类(掉进文本):把一个 text block 缓冲到它结束,判断里面有没有退化信号。
- 如果含
<invoke name="...">伪 XML,就把它解析出来:<invoke>之前的正常叙述保留为 text,尾部伪调用重组成真正的tool_useblock,并给后续 block 的 index 做偏移。 - 如果是“孤儿前缀”变体(text 尾部有孤立成行的
count/court、且紧跟一个真 tool_use),就把尾部那个前缀剥掉、保留正常叙述,真 tool_use 原样透传。这里有双重防误伤:必须“孤立成行 + 后接真 tool_use”两个条件同时满足才动手,像discount、account这种以 count 结尾的正常单词不会被误伤;万一后面接的不是工具调用,就原文回退。
对第二类(参数编码坏):对已知会双重编码的字段(如 AskUserQuestion.questions)解一层编码、清洗掉坏的 \u 转义,还原成合法 JSON。只碰已知的坏字段,不去动那些本来就该是 JSON 字符串的字段,避免帮倒忙。
发生救援时,顺带把响应的 stop_reason 修成 tool_use,让 CLI 知道这轮该去执行工具。
安装
项目地址:https://github.com/attson/claude-proxy
两种方式,任选其一:
一、下载 Release 产物(六平台:linux / macOS / Windows × amd64 / arm64)。到 Releases 页 下对应平台的压缩包,校验 checksums.txt 后解压,把二进制放进 PATH:
1 | # 以 linux/amd64 为例 |
装好后可用自更新命令跟进版本:
1 | claude-proxy update # 检查并升级到最新 Release |
二、源码构建(需 Go 1.23+,零第三方依赖):
1 | git clone https://github.com/attson/claude-proxy.git |
若走源码构建(软链本地二进制),后续升级用
git pull+ 重新go build,而不是update。
用起来
最省事的是一条 run 命令,完全不改你的 settings.json:
1 | claude-proxy run # 等价于 claude,但走代理 |
它会自动探测端口、按需在后台拉起代理,从 ~/.claude/settings.json 读出真实上游转发过去,再用 --settings 内联 JSON 把 base URL 指向本机代理(优先级高于 settings 文件)。想直连?正常敲 claude 就行,两者并存。
嫌每次敲 run 麻烦,可以做个 alias 让 claude 默认走代理(写进 ~/.zshrc 或 ~/.bashrc):
1 | alias claude='claude-proxy run --' |
这层 alias 是安全的:alias 只在交互 shell 的命令行首解析,代理内部调用的仍是 PATH 里真实的 claude 二进制,不会递归套娃。
几个设计上的取舍
为什么要落盘样本。 退化难复现,所以代理默认会把每次流式响应脱敏后落盘成 .sse 样本(token、凭证在写盘前抹掉,样本目录 700 权限,自动轮转只留最近 N 个)。配套一条离线分析命令,能把一个样本按事件拆开、标出退化信号——修 bug 时先拿真实样本喂进状态机跑,比凭空构造靠谱得多。这个项目里新增的每一种退化形态,都是先在真实样本上验证过才动手的。
为什么强调“只碰已知字段”。 修参数编码很容易上头,写一个“聪明”的通用逻辑去猜哪些字段该解码。但那恰恰违背 fail-open:你猜错一次,就把一个本来合法的请求搞坏了。保守到“只认已知坏字段”,反而是更负责任的选择。
自指检测。 代理会检查上游地址是不是指向了自己(回环地址 + 同端口),是的话直接拒绝启动,防止请求在代理里打转形成死循环。
小结
Claude Code 的工具调用退化是个典型的“间歇性、内容层、难复现”的问题。与其等上游修,不如在本机加一层薄薄的、只读不改地转发、出问题才救一把的代理——核心不是修得多聪明,而是永远不把好请求搞坏。fail-open 这条原则,比任何一处具体的重组逻辑都重要。
项目零第三方依赖、单文件二进制、六平台产物,claude-proxy update 可自更新。代码与用法都在仓库里:https://github.com/attson/claude-proxy,感兴趣可以自行取用或改造。
- 标题: 用一个本机反向代理,修复 Claude Code 偶发的工具调用退化
- 作者: Attson
- 创建于 : 2026-09-14 16:30:00
- 更新于 : 2026-09-14 09:04:14
- 链接: https://attson.github.io/p/claude-proxy-tool-call-degrade.html
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。