1 minute read

系列 · Agent 时代的开发者界面(持续更新)

  1. 开篇:没报错,就代表输出没问题吗?(本文)

写在前面

前阵子看到有人在讨论 Agent 的 TUI 和 GUI,两派吵得挺热闹。我刚好两边都踩过:用 Swift 从零写过一个 TUI 渲染引擎,也写过 iOS 上的 Agent GUI 客户端,攒下不少笔记、代码和踩坑记录。

写这组文章之前,我把这些东西翻出来重新梳理了一遍,缺的地方补研究,浅的地方往下挖,前前后后写了十几篇,以后大概还会想到哪写到哪:从 ANSI 转义序列到多端架构,从 Markdown 流式渲染的 O(n²) 陷阱到权限和 diff 的 UX 设计。

不是什么权威结论,就是一个人踩过两条船之后,把想清楚的事记下来。如果恰好对你有用,那就最好了。


这个系列的正文有很多篇,从”为什么 Agent 从 CLI 和 TUI 开始”讲起,偏知识建构。不过在进入正文之前,我想先从一类反复出现的故障说起——它是我做 Agent 界面这几年见过的最典型的坑,也是这个系列”看不见的另一半”。

一、最坑的坏法不是报错而是不报错

这段时间做agent,得到了很多经验和教训,其中有一条经验越来越确定:流式输出最容易漏的一种坏法不是报错,是答案被截断了,而它看起来是完整的。

这条教训的起点是我的非常烂的网络和代理。我的代理链路一直不太稳,断流、超时是家常便饭。上个月我让模型写一份比较长的分析,出来的东西格式工整、段落齐全,但总觉得话说到一半就没了,末尾该有的一半内容根本没有(因为我在提示词里做了一些设置,缺少了一些短小但是非常关键的内容)。

最开始我以为是上下文过长的问题,但是后来在上下文明显充足的情况下还是发生了,就引起了我的注意了。

接下来我以为这就是代理的锅,网络修好了事就算了。但是又一次发生之后,就激起了我的好奇心和斗志了,开始深究下去。

但真正研究下去才发现:就算代理完全OK,这个问题照样有概率触发,包括但不限于以下几种情况:

  1. token 上限会把回答拦腰截断,
  2. 网关可能弄丢最后一个 chunk,
  3. SDK 的默认值会把”连接被悄悄关了”当成正常结束。

代理的问题只是让我撞见它的那扇门,门后面是一整类故障,而它们共同的特点是:问题发生了,工具从头到尾不告诉我!!!

这引出了我给”一次流式输出到底完不完整”设计判定逻辑时定的规矩:complete 只能放在最后,它不能也不应该是默认值,而是前面的检查全都没命中才轮得到的兜底。换句话说,“没证据说坏了” 不等于 “证据说没坏”,看起来完整,得举证才能算数。

于是我动手去翻手里几个我比较熟悉的开源项目的代码,想找到相关内容,看看它们是如何做的。结果就发现了一些很有意思的事。

先把结论放这:判定一条流式回答完不完整,在内容层永远做不出来,必须下沉到协议层。 下面讲为什么,以及三个真实项目各自是怎么(没有)解决它的。

二、为什么”看起来完整”是常态

先交代背景。做过 Markdown 相关应用的人都知道,Markdown 的格式是后置的,代码块要看到收尾的三个反引号才知道代码结束了,表格要看到下一行才知道上一行是不是最后一行,这个会在这个系列的后续展开,这里只说它和截断的关系:

因为格式后置,很多时候截断的回答渲染出来毫无破绽。

模型通过 SSE 流式吐内容,每个事件大概长这样:

data: {"choices":[{"delta":{"content":"## 结"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":"论\n\n好的"},"finish_reason":null}]}

data: {"choices":[{"delta":{},"finish_reason":"stop"}]}

data: [DONE]

正文一个 chunk 一个 chunk 来,每个 chunk 自己不带结束信息。现在假设流在第二个 chunk 之后因为网络问题断了。你拼出来的文本是 ## 结论\n\n好的。看起来渲染出来是一段完全正常的 Markdown,没有任何报错,也符合我们阅读的习惯,我们无法感觉到这里出现了问题。

流在这里断了,但没人告诉你

所以”看起来对不对”不能作为判据。

三、complete 不是默认给的

到了协议层,第一个要纠正的是默认值。

绝大多数系统的默认值是反的:拿到流,就默认当完整。try 包住、异常没有抛出、循环正常结束就代表这次回答可用。这个默认值错在哪?“没证据说坏了”不等于”证据说没坏”。 结束不是一个自然发生的事实,是一个需要证明的事件。所以我的规矩是:complete 只能放在最后,因为它不是默认值,是前面的检查全都没命中才轮得到的兜底档。

3.1 从故障形态推状态机

“结束需要举证”,那要举哪些证?我的做法是把失败形态一个一个枚举出来,每种形态对应一个状态:

transport_error        连接本身就失败了
no_end_marker          连接活着,但流被关了,结束标记没等到
buffer_residual        流正常关了,可缓冲区里还剩半个没解析的事件
missing_finish_reason  一切正常,但从头到尾没见过结束原因
complete               以上全部排除,才轮到我

这五个状态是我多次尝试后得到的失败模式的穷举——从外往里数,网络连接、网络流、事件、信号,每一层各有一种坏法。 complete 排在最后,是因为它是排除下来的兜底产物。

流式完整性状态机:complete 是排除后的兜底

枚举的价值在于它和 “try/except” 的区别:异常只能抓到你预想过的失败,枚举强迫你把”一条流可能怎么坏”逐层看过一遍。看完你会发现,多数实现的防御只覆盖了第一层: 网络连接错误依靠异常体系兜底,但是后面三层全是裸的。

3.2 结束需要双保险

直接看第四层,这是最容易被省掉的一层。

一条流正常收尾,应该有两个互相独立的信号:关闭标记(SSE 的 data: [DONE])和结束原因(最后一个数据 chunk 里的 finish_reason:stop / length / tool_calls…)。结束原因可以理解为协议替 Markdown”补发的结尾信息”,我们在正文里看不出来的事,协议单独告诉你一次。

这两个信号缺一不可,因为它们会单独阵亡。最典型的死法:网关转发时把最后一个数据 chunk 弄丢了,只放行了 [DONE]。只查关闭标记的实现,对这种故障全线失守;反过来只查 finish_reason 也一样。我的判定标准是三条,任一命中就判不完整:结束标记没等到、缓冲区里有没解析完的字节、从头到尾没见过一个结束原因——前两条各管一层,第三条管的就是”证人缺了一个”的现场。

如果你认真的看了之前的文字,会注意到:前面枚举了四个失败状态,这里怎么只剩三条?不矛盾。transport_error 和 no_end_marker 在观测上折叠成了同一条,因为连接都死了,结束标记必然没等到。状态机按原因分四类,是为了标准化的进行原因归类,而这条记录到底是”连接断了”还是”连接活着但流被关了”,事后排查时是两回事。

一句话:结束需要双保险,缺一个都不算数。

3.3 收尾不能押注在一个等不来的信号上

第三类坏法藏得更深(也是源自于我稀烂的网络,笑)。SSE 的事件之间靠空行分隔,解析器攒到一个空行才敢处理前面的事件。如果某个超大事件中间网络卡住,空行永远不来,这个”攒了一半的事件”就无界地占着内存——业务逻辑永远不触发,而且没有任何报错,进程看上去完全健康。

对付它的办法不是”耐心等”,而是大小上限:上限到了必须自己能触发收尾,落到的状态是 limit_exceeded,不是”再等等”。等不来的信号不能做唯一的收尾条件——这是同一条规矩的第二个侧面:complete 不能当默认值,”结束”也不能押在一个不保证会来的信号上。

3.4 记状态,不抄现场

最后一条和前面几条画风不同,但同样来自实践(依然源自于我稀烂的网络,笑哭):失败记录里只许记状态,不许抄原始报文。

原因是断流没有体面的死法。一条被掐断的流,断点可以停在任何字节上,正好停在一段敏感文本中间是常态。所以摘要和日志里写的是”这条记录落在 missing_finish_reason“,而不是把半截报文抄进去。因为要知道的是它坏了、坏在哪一类,不是它坏成什么样。这条值得做成测试断言,而不只是代码约定:约定很容易在某次重构里被悄悄拆掉,断言则不太会。

四、拿这把尺子去量真实世界

带着这套判定标准,我去翻了三个主流开源项目的源码。过程和结果给我带来了很多有意思的事,尺子还没落下,先撞上两个反转。

反转一:连”数清楚装了多少个检查”都得先搞清架构。

我最初的结论是”截断检查缺失在某个库的 generate 主循环里”,顺着代码查下去才发现,这个仓库里有两套同构的 LLM 引擎:我看的那套库,主 agent 根本不走;真正的主引擎在另一个包里,有自己的 requester、自己的 finish reason 枚举、自己的空响应检查。完整性检查这种事,装错了包等于没装。如果不做这一层核实,我后续所有的”有没有防护”结论都会张冠李戴。

反转二:不是静默,是写错了对象。

搞清楚架构之后再问”主 agent 的回答被截断时,用户看到什么”:截断标志其实被算出来了,却在 turn 结束事件里被丢弃;TUI 倒是会弹一条提示,但文案是从工具调用视角写的——纯文本回答被截断时,用户看到的是”Model hit max_tokens — no tool call was emitted”,不会意识到上面的回答本身是半截的;无头模式和编辑器插件客户端则完全没有感知。所以准确的表述不是”毫无防护”,是:他们知道截断发生了,但警告写错了对象。

把尺子正式放上去,三个项目是三个数据点:

kimi-cli(Python,已归档)——零个检查。

finish_reason 全库生产代码零处读取。SSE 解析整个委托给 SDK,而 SDK 的默认行为恰恰是最差的那种:[DONE] 只是提前退出,服务端直接关连接时循环自然耗尽,不抛任何错,”收到 [DONE]“和”连接被悄悄关了”对上层完全不可区分;半个事件没有空行分隔则被静默丢弃。它仅有的防线是看”收到了什么内容”的形状启发式——空响应报错、纯思考无正文报错——但只要流出过任何一段正文,半截回答就被无条件收下。讽刺的是它每次请求都主动设 max_completion_tokens,token 截断是常态风险。上个月它发了 1.52.0,最终版,仓库归档。这个漏洞永远定格了——库默认值照单全收的代价是永久的。

kimi-code(TypeScript,继任者)——装了一个点,还是对的点。

它修了前者的可观测性:统一的 finish reason 体系(OpenAI length、Anthropic max_tokens、Gemini MAX_TOKENS 归一化),信号一路透传到事件和错误文案。截断的硬检查也确实存在——在子 agent 的 final summary 边界上:summary 被截断,整个 run 失败。这个点的位置选得很有水平,因为 summary 是给 parent 的机器消费的契约,半截契约比没有契约更糟。但主 agent 自己的回答,就是上面反转二的那一幕——同一个故障模式,在一个边界上硬检查,在另一个边界上静默加写错对象的提示。

codex(Rust)——四层检查,做成了协议层不变量。

它走的协议不同,等价信号是 response.completed 事件。这个检查它不是做了一层,是四层:SSE 层、WebSocket 层、事件映射层、turn 主循环,各自独立校验”流关了但终止事件没见过”,任何一层命中就显式注入错误,默认重试数次,重试期间 TUI 显示重连计数且不写入聊天历史。底层 SSE 库对流关闭但无终止标记的情况什么都不做——所以这四层是手工固化的。在 codex 里,”流断了、回合却看似正常结束”这条路径不存在。

  • 吐槽一句,codex 不愧是成熟的优秀产品,不仅在这里,在进行其他方面的对比研究的时候,codex 明显在各种边界处理上更加优秀。

三个数据点放在一起,规律很清楚了:完整性判定不是”有或没有”的问题,是”在多少个边界上装了多少个检查”的问题。 而比数量更难得的是质量——装在正确的边界上,写给正确的读者看。装检查不难,这才是难的部分。

同一类故障,三个项目,三种答卷

我把 kimi-code 那个不对称整理成了 issue 提给官方,附了复现和修法建议,等 approve 后提 PR。

五、核实先于判断

这次从发现问题到发出 issue,最大的教训不在技术层面,而是更深处的信任:一句不实的描述足以让整个报告的可信度归零。

如果我按最初的口径发出去——“主循环对截断零防护”——维护者第一次打开 TUI 代码就能证伪我。是复查推翻了两个假设之后,论点才站得住的。而好的缺陷报告还有一个技巧:论点落在不对称上。“这里坏了”不如”同一个故障模式在 A 处硬检查、在 B 处静默”有说服力,因为后者把项目自己已有的价值判断当成了标尺,维护者很难拒绝补平自己的逻辑。

这和第 3 节那条”记状态,不抄现场”其实是同一件事的两面:对自己的日志脱敏,对别人的报告核实;都只交付经过验证的状态,不交付未经核实的现场。

六、这个系列要讲什么

回到开头那句:截断的回答看起来是完整的。

这个系列鸽了很久——其他工作也在深入,想法一天一个样,总觉得还能再挖。现在是时候慢慢放出来了。

这个系列剩下的部分,大多数讲的是可见的那一半:从 ANSI 转义序列、字符网格、Markdown 流式渲染,到权限 UX 和多端架构。

而这一篇是看不见的另一半:渲染引擎再聪明,也只能渲染”已经到达的字节”;字节到没到、到全没有,是协议层的事,渲染层永远无权裁决。 两个世界的接缝处,就是这个引子里的全部事故。

后话:这个 bug 还有个更微妙的处境。现在评测 Agent 的流行框架喜欢把指标分成结果、过程、效率、风险四层,而它哪一层都放不进去:它不改变 agent 的行为,只改变”你看到的是不是真的”。四层评的是能力,它考问的是评测的地基。这个话题值得单独写一篇,这里先立个旗子。


附:我自己设计的流式完整性检查清单

状态机(前面的先判):
  transport_error / no_end_marker / buffer_residual
  / missing_finish_reason / complete(兜底,非默认)

判定不完整的三个充分条件(任一命中):
  □ 结束标记未收到(如 SSE 的 [DONE])
  □ 缓冲区有未解析残留
  □ 全程没有任何 chunk 携带结束原因
    ⚠ 结束需要两个独立证人(关闭标记 + 结束原因),缺一个都不算

半帧防护:
  □ 单事件/单缓冲有大小上限
  □ 上限到达自行收尾 → limit_exceeded
    ⚠ 等不来的信号不能做唯一收尾条件

记录边界:
  □ 失败摘要只记状态与类别,不抄原始报文
    ⚠ 截断的报文可能停在敏感文本中间