2 minute read

系列 · 我的评测工具链(持续更新)

  1. 从 trace 到 eval:trace 设计、agent eval 方法论、一个 eval harness 的实现,和它抓到的上游并发 bug
  2. 给 skill 建门禁:一天里的三种沉默失败、一次红线写法实证,和它抓到的上游 bug(又一只)
  3. 我给 skill-up 报了一个不存在的 bug
  4. CI 红、本地绿:一次「平台差异」误诊,和 merge 干净不等于语义兼容
  5. 回答被截断,你的 CLI 知道吗:四个工具、六种故障、108 格矩阵,和一个差点发布的错误结论(本文)
  6. 给流式协议造故障:三协议 mock、once 注入、字节级送达证明,和 strings 阴性不等于代码不存在
  7. 考官带伤阅卷:对 Agent Skill 做故障注入的完整性实验

四个 CLI、三种流式协议、六种故障、108 次确定性注入。结论里最刺眼的那一条,最后发现是我自己的 mock 造的假——这篇文章包括我是怎么冤枉 codex 的。

1. 开场:半截答案比报错可怕

用 AI CLI 干活的人大概都见过这个场面:让它写一份长报告,屏幕哗哗流了几分钟,停在一句看起来完整的话上,光标一闪,任务「完成」。你把报告拿走,直到用的时候才发现,结尾少了一截。

报错不可怕,报错你会重试。半截答案可怕,因为它长得和完整答案一模一样。 流式协议里能让回答变半截的原因不少——token 上限耗尽、连接中途断开、终止事件丢失——协议层各有各的形态,用户层却共享同一个性质:不细看,发现不了。

我日常用的 CLI 就撞出过几次长文无声收尾,没有任何提示。这个月把这个问题做成了正式测评:当流式回答被截断,各家 CLI 到底能不能发现?发现了,告不告诉用户?告不告诉机器(会话记录)?

2. 测评设计:六种故障,四级判定

被测对象是四个 CLI,覆盖三种主流流式协议。版本全部钉死并写进记录表——这是可复现性的底线:

工具 版本 协议
codex 0.156.1 OpenAI Responses
claude code 2.1.282 Anthropic Messages
kimi-cli 1.52.0(归档定格) OpenAI Chat Completions
dsh(deepseek-harness) 0.1.7-rc.2 三种协议各跑一行

故障不等真实网络施舍:本地起了一个 mock provider,六种故障做成请求参数,逐字节可控。

编号 故障 协议层形态
F0 正常完整流 对照组:工具应正常完成
F1 中途干净断流 发到一半 TCP FIN,无终止事件
F2 丢终止事件 内容全部送达,唯独收尾事件缺席,流正常关
F3 半个事件断流 SSE 事件发到一半(JSON 都不完整)后 FIN
F4 length 截断 正文充足,以 finish_reason=length / max_tokens 收尾
F5 think-only 截断 reasoning 耗尽 token 上限,正文一个字没有

F5 同时兼任阳性对照:kimi-cli 的底层框架对「空响应」有现成的重试逻辑,F5 必然触发它——如果 F5 都测不出反应,说明注入链路本身坏了,「未检出」的结论作废。这是这个系列的老原则:裁判和考题,都要先校准。

判定看两个维度。用户可见的 L 级:L0 静默 / L1 弱信号或信号指错对象 / L2 明确告知「回答被截断」/ L3 告知且自动恢复。机器可见的 transcript 打标:落盘会话里有没有结构性标记。注入语义是 once——每格只有第一个请求命中故障,重试走健康流,这样「工具有没有自救」和「自救后能不能交付」能分开看。mock 侧逐请求记录实际发出的字节数和终止事件送达情况,作为「故障确实送到了」的证据。每格跑 3 次,全部格子 3/3 一致,无 flaky。

mock 的实现、注入语义踩过的坑、TUI 画面的采集方法,都在姊妹篇《给流式协议造故障》里,本文只保留结论需要的部分。

3. 总矩阵:60% 静默,没有一个工具会说人话

108 格(6 行 × 6 故障 × 3 次,含 F0 对照)跑完。F0 对照组 6/6 全绿,才进的故障注入。结果:

工具(协议) F1 中途断流 F2 缺终止事件 F3 半事件断流 F4 length 截断 F5 think-only
codex(responses) L3 ✓恢 L3 ✓恢 L3 ✓恢 L3 ✓恢 L3 ✓恢
claude code(anthropic) L0 ✓恢 L0 L0 ✓恢 L0 ✓恢 有标 L0 ✓恢 有标
kimi-cli(cc) L0 ✓恢 L0 L0 ✓恢 L0 L3 ✓恢 有标
dsh(cc / responses / anthropic) L0 ✓恢 L0 ✓恢 L0 ✓恢 L1 有标 L1 有标

(✓恢 = 最终交付了完整回答;标 = transcript 有结构打标。dsh 三协议行行为一致,合并展示。测试日期 2026-09-25。)

L 级分布(30 个故障格):

级别 含义 格数
L3 告知 + 恢复 有信号且自动补齐完整回答 6(20%)
L2 明确告知 说出「回答被截断」 0
L1 弱信号 信号存在但指错对象 / 用户不可见 6(20%)
L0 静默 无任何信号 18(60%)

两个值得记住的数字。检出(L2 及以上)只有 6/30——而且这 6 格全是「重试信号 + 完整重放」形态,没有任何一个工具说出过「回答被截断 / 不完整」这级人话,L2 整个空着。L0 占 60%:多数截断发生在你和工具之间,无声无息。

4. 四种性格

矩阵每一格背后都是一种设计性格。挑最有戏的四个侧面。

codex:能检出能恢复,但只对人眨眼,不对机器留痕

五种故障全部 L3:流出 ERROR: Reconnecting... 1/5,自动重试,完整重放,exit 0,行为层面是四家里最完备的。一次 F4(length 截断)的实际输出:

……(P01–P24 逐段流出)
ERROR: Reconnecting... 1/5
……(重试命中健康流,完整重放)
$ echo $?
0

但有两个问题。其一是文案归因偏移:F4/F5 是干净的协议级 length 截断,连接完好无损,codex 报的却是「重连」——信号有、恢复有,说的不是一回事。TUI 模式下这条提示还是转瞬即逝的状态行,恢复后即被重绘抹除,我要用 0.4 秒轮询专门取证才能证明它存在过。其二是 transcript 零打标,F1–F5 全部如此:故障次和重试次的回答以同构形式落盘,没有 error / Reconnecting / incomplete 任何结构标记。用户可见性 L3,机器可见性是零。对「会话记录作为审计或训练数据」的场景,这是实质性盲区——事后翻记录,你无法发现这次回答曾经失败过。分布式系统对这类问题有个老解法:墓碑优于删除——作废的调用不抹掉,留一条带失效原因的状态,迟到的人随时查得到真相。codex 的 transcript 恰恰没有墓碑:故障次和重试次长得一模一样,沉默被当成了成功。

dsh:镜像分裂——机器记得清清楚楚,用户只得到一个裸 exit 1

F4/F5 是最有戏剧性的一格:正文完整打印,然后进程 exit 1,dsh 自身零文案——画面上唯一的失败迹象是 pnpm 包装器的 [ELIFECYCLE] Command failed with exit code 1.,裸跑二进制的用户连这行都看不到。但它落盘的 session 末条精确记着 turn/end {"reason":{"kind":"max-tokens"}}。

检测逻辑存在且准确(exit code 都变了),唯独没有翻译成用户可见的一句话。和 codex 放在一起看,两家各瞎一只眼:一个只告诉人,一个只告诉机器。

claude code:万应静默重试,把确定性故障当瞬时故障治

F1/F3(连接断)和 F4/F5(max_tokens)一律静默整轮重试,然后交付完整回答,exit 0,用户侧零信号。transcript 里第一次回答带着 stop_reason: "max_tokens" 落盘——所以 claude 是「知道,但不说」。

对连接中断重试是合理的;对 max_tokens 也整轮重试就值得商榷:同样的 prompt、同样的 token 上限,重试大概率再次撞墙。这次能恢复,是因为 mock 的 once 语义让重试恰好落到健康流上——真实世界没有这种善意。确定性故障和瞬时故障共用一套重试策略,是设计上的偷懒。

kimi-cli:有重试基建,但 length 截断从未接线

F5 是唯一亮点:think-only 截断触发了框架层的 StepRetry(error_type='APIEmptyResponseError'),自动重试并完整重放,transcript 有标(L3)。说明重试基建是有的。但 F4——正文充足、finish_reason=length——完全无声(L0):length 类截断从没被接进「需要重试或告警」的判定。

kimi-cli 已归档定格在 1.52.0,这个洞没有修的机会了。但它的 TS 后继 kimi-code 有同样的问题:主 agent 的截断被静默当成功接受,而子 agent 的 final summary 截断有硬检查——同一个失败模式,两种待遇。测评前一天我已给 kimi-code 报了 issue #4012,修复分支在本地做着。这算是这个系列的传统:测到什么,就报什么。

5. 翻案:我差点发布的错误结论

现在讲这篇文章里最重要的一段。

初版矩阵里,codex 的 F4/F5 是 L0——完全静默,单请求,无重试,无信号。这和第 4 节的画像直接冲突,更和源码冲突:codex 0.156.1 的 codex-api/src/sse/responses.rs 里,response.incomplete 事件明确会转成 ApiError::Stream 并进入重试路径。代码说有,实测说没有。

我的第一个假设是「exec 非交互模式吞了这条重试路径」——行为按模式分差异,很合理。于是加测交互模式:tmux 里起真 TUI,同样的故障注入。结果一样静默。模式假设被否证。

那就只剩一个嫌疑人:我自己。把 mock 发出去的字节逐事件核对,真相有点难看——

初版 mock 在 Responses 端点把 length 截断发成了 response.completed 事件裹一个 status: "incomplete" 字段。而真实 API 从不这么说话:Responses 对 length 截断的终态是独立事件类型 response.incomplete,携带 incomplete_details.reason。再回头看 codex 源码:response.completed 分支的 ResponseCompleted 结构体根本不解析 status 字段——status=incomplete 被当成功收下,什么信号都没有产生。

换句话说:不是 codex 收到了截断信号装没看见,是我的 mock 压根没把信号说出口。用法语考学生加法,学生没反应,差点判他不会算术。

中间还有个插曲,差点造成二次误判:往被测的 codex 0.156.1 二进制里搜 response.incomplete 整串,0 命中——如果据此写下「二进制里没有这个事件的处理器」,就错上加错了:

$ strings codex | grep -c 'response\.incomplete'
0        # ← 事件名整串零命中(Rust match 被 LLVM 内联成立即数比较,不进 rodata)
$ strings codex | grep -c 'response\.completed'
12       # ← 同结构的事件名却有 12 处副本(遥测等他用)
$ strings codex | grep -cE 'Incomplete response returned|incomplete_details'
2        # ← 处理器的证据串一直都在

strings 阴性,不等于代码不存在。

修正 mock 方言后全量复测:codex F4/F5 在 exec 和 TUI 两种模式下 3/3 全部 L3(Reconnecting + 完整重放);dsh 复测行为不变(它底层的 pi-ai 两种方言都认);claude 和 kimi 的协议方言本来就正确,不受影响。初版那两个 L0 是方言伪影,作废,证据保留并单独标注——它们现在是「测试夹具如何制造假阴性」的教学样本。

这个系列的第三篇讲「观测会骗人」,这一篇得补上一条:考题也会骗人。「未检出」这种阴性结论必须附方言级的阳性证据,否则你永远分不清是工具瞎了,还是自己没把话说对。

6. 沉淀

给三类读者各留一条。

用户:长回答到手,先看结尾再使用。目前没有工具能稳定替你盯着这件事——60% 的截断场景下它是无声的。对重要任务,让 agent 收尾时自检一遍交付物完整性不是玄学,是真的有东西可验。

工具厂商:截断检测其实多家都有——codex 的重试链、dsh 的 max-tokens 打标、claude 的 stop_reason 落盘、kimi 的 APIEmptyResponseError——缺的都是最后一公里:把结构信号翻译成一句人话(「回答因输出上限被截断」),并在 transcript 里留下结构标记。检出不难,告知才是缺口。原则其实很老:作废必须是一个带明确语义、走在正常响应路径上的信号——「成功码 + 一次断流」是最坏的答案,调用方会照着 200 把「已完成」写进自己的状态,那是一个永远不会被纠正的谎言。另外,确定性故障(max_tokens)和瞬时故障(连接断)不该共用一套重试策略——失效理由的类型化同理:「有意作废」和「实例没了」导向相反的重试决策,透传中被磨平成笼统的 error,就等于弄丢了决策依据。

做测评的人:阳性对照要细到协议事件名的粒度。「工具没反应」和「故障没送达」之间隔着一层必须自证的送达——mock 的字节级日志就是干这个的。以及,发布前把每个刺眼结论当嫌疑人审一遍:最刺眼的那个,往往是你自己的。

这个系列写到现在五篇,抓到的「bug」依然大多不在代码里——在缺失的 trace 里、在对照组的纯度里、在观测者的先入之见里、在「merge 成功」四个字的承诺里,这一次,在我自己 mock 说错的协议方言里。工具链的价值不在于永不出错,而在于错了之后能被抓回来——这次的代价是两格子返工,不是一篇错误的结论。

本次测评的 108 格逐格证据、12 格交互附录和 mock 代码,整理后会开源。


本文涉及的工具:codex 0.156.1、claude code 2.1.282、kimi-cli 1.52.0(归档)、deepseek-harness 0.1.7-rc.2。上游记录:kimi-code#4012(主 agent 截断静默接受)。测评日期:2026-09-25。

Tags: ,

Categories:

Updated: