适合谁读: 在生产环境跑浏览器侧 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% 的命中在生产上跑着。
前缀是会话资产,不是本轮分类的结果。这一句话如果早点想清楚,后面三件事一件都不会发生。
下次要查时延,先看是不是第二次模型调用、用户在审批卡上的阅读时间、还是续写覆盖了计时,再决定动代码。墙钟和首包是两个数字,不要混。