web agent harness如何提升回答速度

Chat Agent、对 LLM 调用时延有困惑、或者发现「轻量对话」反而比正式操作更慢的开发者、Design Engineer

Blogagent

适合谁读: 在生产环境跑浏览器侧 Chat Agent、对 LLM 调用时延有困惑、或者发现「轻量对话」反而比正式操作更慢的人。


教师说完「好的 谢谢」,界面转圈。十点九秒后,助手回了十八个字:「不客气,有什么需要随时说。」

这不是网络问题。那条线程二十分钟前刚跑完一轮写教案,同样的 21 KB 请求体,首包只要 3.3 秒。说谢谢的那轮,首包等了 7.8 秒。

我们拆了很久才弄清楚,这一次慢,不是一个原因,是三个。而且三个看起来都像合理决策。


先说清楚这条链路

这不是本地脚本直连 API。

教师在浏览器里说话,请求先到我们的应用服务器,服务器组装好上下文再调 GLM,GLM 的流式响应再经服务器转发回浏览器。每一轮对话走的是:浏览器 → 应用服务器 → LLM 网关 → 原路返回。

时延里那条「开跑、落库、装会话 ~1.4s」,是浏览器请求落到服务器、查数据库、恢复会话状态的时间。还没到 LLM,1.4 秒已经花出去了。

这层架构税消不掉。本地 Agent 直连可以省掉大半,但那不是浏览器里的用户该接触的形态。所以后面讨论的所有优化,目标都是「已经花了 1.4 秒进门费之后,剩下的 7+ 秒里哪些不该这么长」。

还有一点要先说:无状态的 Chat Completions 没有「服务端只发一句谢谢」这种接口。每一轮对话,客户端都会把完整历史带上。教师觉得自己只说了谢谢,模型看到的是整条线程——上一轮写教案留下的工具调用、审批结果、课标检索,全在。

真正该问的不是「能不能少发」,而是:发过去的东西里,有没有下一轮根本不该带的内容?


根因一:历史推理在给自己加速

把一条真实线程的持久化 transcript 拆开看(约 90 KB,21 条消息):

占比 内容
59% 模型历史推理(thinking)
21% 真正给教师看的正文
14% propose_document_patch 参数
<1% 工具调用

最长的一条助手消息,三万两千字节里有两万八千是推理。

问题是这些推理被写进了持久化 transcript,然后下一轮请求把整份 transcript 带上——包括那些推理。模型不是在读自己写给教师的话,是在读自己上一轮想了什么。

结果是复利:推理 → 存盘 → 下一轮再带上 → 输入变大 → 推理更久 → 再存更多推理。同一条线程,提示从 8 KB 涨到 17.5 KB 时,私有推理从 2.3 秒涨到 28 秒。

第一反应是「系统提示太长」或「Skill 文件太大」。Skill 全文只有大约 1 KB,本轮政策里也不含教案正文。体积不在模板上,在旧的思考上。

改法只有一句话: 发给模型的视图丢掉不可回放的 thinking,持久化记录不动。只有 Responses 协议把 reasoning 序列化成必须成对出现的 item 时才保留,否则协议会坏。发往模型的上下文从 90 KB 降到 38 KB,少了 58%。


根因二:寒暄按「深度」在想

这个最窝囊。

路由器早就把「好的 谢谢」标成了 brief_reply_only。但请求里的 reasoning_effort 还是上一轮备课留下的 high。教师在输入框选的是「深度」,系统就照单全收,不管这一轮是写教案还是回一个谢谢。

GLM-5.3 在 Completions 上不写 reasoning_effort 时还会默认拉满思考。这一轮什么都没选,就等于让它按最高档跑。

我们当时做了一个错误修复:轻量轮强制把 thinkingLevel 改成 low。

但这意味着教师选的「深度」在说谢谢时会被系统悄悄换掉。控件显示深度,实际发的是轻度。这个控件就成了假的。

撤回。思考强度以输入框为准,包括谢谢。寒暄加深度思考会慢,那是用户选择的结果,不是系统该替他「优化」掉的。


根因三:为了让请求变小,把缓存整段作废

这个最难发现。

那轮致谢的 usage:cacheRead = 0,cacheWrite = 0,input 4834 token 全价预填充。工具数是 0,Skill 目录是空的。

上一轮写教案带着完整工具表和 Skill 目录。这一轮路由器认为是「轻量对话」,把工具和 Skill 全部清空——省下的是几 KB 的 schema,丢掉的是整段 21 KB 历史的缓存资格。

GLM 的上下文缓存是隐式的,规则只有一条:从请求开头起字节级完全相同的那段才被复用。工具列表改一个字段,主机从 token 0 重算。

用更小的输入换来了更贵的预填充。

正确的做法是把约束放在请求的尾部:「这一轮只回一句」写在 turn context 里,不要动前缀。模型看见 propose_document_patch 没有关系,政策告诉它这一轮不能用就行了。审批门还在,看见不等于能写入。

这是 Claude Code、Cursor 一直在做的事:会话内工具表稳定不变,模型行为由政策约束,不由 schema 是否存在来决定。

顺带修了两个会改工具 JSON 的细节:read_document 的参数 schema 不再因「是否必须整份读取」而变形;查询工具不再把已选主题写进 description。主题绑定在执行期,不在 schema 里。schema 一变,缓存从第一层工具定义就断。


改完之后的数字

同一条线程,部署后连续四轮(工具表 21 个,Skill 目录字节级不变):

时间 新算 input cacheRead 命中率 首包
22:24:08 7728 0 未命中 3.5s
22:24:21 7760 0 未命中 3.8s
22:24:51 370 7616 94% 4.2s
22:25:35 1005 7872 89% 3.4s

前两轮未命中是正常的——线程从早上空到晚上,隐式缓存过期,第一轮等于重建前缀。大约三十秒后开始稳定命中,每轮新算只剩几百到一千 token。早上那次致谢是 4834 token 全价;现在大约 7800 token 被复用,每轮新算只剩几百。

还剩的 3.4 秒首包是这台网关的下限。cacheRead 确认计算被复用了,但这台 GLM 网关没有把首包压到亚秒。要再往下,只能换主机,不是再给「谢谢」开特例。


三件事对照一眼

问题 当时的做法 实际效果 后来怎么改
历史推理 原样回传 Completions 每轮复利,17.5 KB 对应 28 秒私有思考 发往模型的视图丢掉不可回放的 thinking
致谢的思考强度 分类器悄悄改成 low 控件作假,教师选了不算 始终以输入框为准
轻量轮的工具 tools: [] 前缀作废,4834 token 全价预填充 工具表会话级稳定;政策放在 turn context 尾部

三件事都是独立的错误,叠在一起才产生了那次 10.9 秒。拆清楚之后,前缀再也不用在应用层打补丁。89%–94% 的命中在生产上跑着。

前缀是会话资产,不是本轮分类的结果。这一句话如果早点想清楚,后面三件事一件都不会发生。

下次要查时延,先看是不是第二次模型调用、用户在审批卡上的阅读时间、还是续写覆盖了计时,再决定动代码。墙钟和首包是两个数字,不要混。

×