写 prompt 的人和写 agent harness 的人看 LLM 调用的角度不一样。
上下文工程的视角里,LLM 调用是一个抽象的 messages 数组:[system, user, assistant, system, ...]。任务是设计这个数组的内容,怎么通过编排上下文数组的结构,约束模型的单次输出。这个视角有两个隐含的前提。一是它关心的是终态:把数组声明成什么样,模型给出的那一次输出对不对,中间怎么生成的是黑盒。二是它假设调用之间相互独立:每一次调用的 messages 都是重新构建的,这一次怎么拼,不影响下一次。
Hermes 工程的两个前提正好都反过来。
它关心的不是终态而是过程:token 还在流入、调用还没结束的时候,状态机就要介入做决策。它要的不是"声明一个数组拿一个结果",而是"控制一次调用怎么发生"。
它面对的也不是相互独立的单次调用,而是跨调用累积的序列:这一次的构建会直接影响下一次调用的代价和质量。这不仅要考虑中间每次输出的正确性,也要保证这一连串的调用能持续跑下去。
关心过程而非终态,关心序列而非单点。这决定了 Hermes 工程不能停在 messages 数组这个抽象上。
一、底层协议选择
Anthropic 协议把 system 抽到顶层、content 原生是块数组、工具调用以 tool_use/tool_result 块的形式在 messages 里流转;OpenAI 的 completion 协议则用 tool_calls 字段加独立的 tool 角色消息。这层差异最要命的地方,落在工具参数的流式上。Anthropic 把 tool_use 当成一个 content 块,流式时通过 content_block_start(带 name)和 input_json_delta(参数逐片到达)把它一段段吐出来,参数是作为块内的增量流式的;OpenAI 则把工具调用塞在 tool_calls 字段里,流式时拆成 delta.tool_calls 的碎片,name 和 arguments 分散在不同 chunk 的 delta 里拼。两边都"支持"工具参数流式,但状态机怎么拼、从哪个事件拿到 name、参数增量挂在哪个结构下,完全是两套写法。上下文工程可以无视这些——它不需要连续的工具调用ReAct;Hermes 工程不能,因为状态机要直接架在这些块结构上,边收边拼。
不仅如此,即使用了相同的 OpenAI completion 协议、同样的流式输出,在 tool_call 块上,不同的 provider 也会表现出明显不同的行为。以这个实验为例。
相同prompt("写一个 200 字小猫故事并保存到文件")、 write_file 工具、OpenAI 兼容 streaming 接口,Kimi和GLM的行为:
| 指标 | Kimi K2.6 | GLM 5.1 |
|---|---|---|
| 首字延迟 | 1.5 s | 15.4 s |
| arguments 拆分片数 | 181 片 | 1 片 |
| 总 chunk 数 | 182 | 2 |
Kimi 的tool_call块内部能看着故事里"小花是一只橘色的小猫"一字一字蹦出来,是逐 token 流式输出。而GLM 是 15 秒全程静默,最终用一个chunk 把整个 JSON返回 。stream=True 在 GLM 这种场景下等于摆设。
这个差异只能跑实验测出来。这恰恰说明,Hermes 工程在最底层的协议上,就要比上下文工程考虑更多的东西。 上下文工程的流式输出只是局限于UX上的体验优化——用户感觉模型在说话。但在 hermes 工程里,tool_call块内token 还在流式接收的时候,状态机就要做出决定:
- 解析arguments时,里面出现
rm这种危险关键字,要不要立刻 abort,而不是等全部输出完再判断 - tool_call已经拿到 name,要不要先在 UI 上打"正在调用 X",或者已经拿到了某个指定参数,是否触发一些回调事件;
- 对于写入工具,单个长参数是否要及时输出流式(不然客户端不知道是不是卡死了)
这些决策的前提,是"信号早于完成"——得在调用还没结束时就拿到中间状态。harness 如果假设"流式能提供状态变更的信号",它在 Kimi 上工作良好,在 GLM 上整个状态机要阻塞直到全部工具参数输出完毕。
不仅是协议不同会影响状态变更信号的解析,就连相同协议下不同厂家的行为也不同。到这里,选哪个协议就是一个必须正面做的工程决策。从 Agent 框架的角度,Anthropic 的协议是更顺手的:工具调用以 tool_use/tool_result 块同构地待在 messages 里,状态机不必在"消息"和"工具"两套结构之间来回切换;流式事件也分得更细,content_block_start 一来就拿到 name、input_json_delta 逐片到达,那几个"信号早于完成"的决策才有依据。而 OpenAI 的 completion 协议胜在兼容性——生态广、对接的 provider 多。
但代价是,一旦为了适配某个协议,整个 Hermes 层就会和这个协议的特性产生耦合。现在 OpenAI、Anthropic 都有对方没有的功能(显式缓存断点、扩展思考块、并行调用语义……),贴着谁写,就吃谁的特性、也被谁的缺失卡住,也就无法通过实现一个“翻译层”统一全部的底层协议。这正是它和传统上下文工程的根本区别:上下文工程站在抽象之上,一套编排逻辑哪家都能跑;Hermes 工程踩在抽象漏下来的那一层,贴着具体协议、甚至贴着具体厂商写。
二、缓存与压缩
上下文工程关心一件事:这次调用让模型输出符合预期。Hermes 工程要多关心一件:这次的构建会不会让下一次调用变差。
变差有两条途径。一条来自 prompt cache 失效,付出 latency 和 cost 的代价。另一条来自上下文管理丢掉关键内容,agent 在信息不全上反复打转,迭代陷入死循环。缓存这一节落在第一条途径上;压缩看似只对应第二条,但它改动 prefix 的动作同样会触发第一条,于是和缓存正面对立——这是后面第三部分要谈的。
缓存
主流服务商(OpenAI、Anthropic、Kimi、GLM)的 prompt cache 大致同构:把 prefix 按固定 block size 切片,每个 block 算 hash,hash 串起来形成链。匹配从头逐 block 走,命中就推进,不命中直接停。前面任何一个 token 变了,后续所有 block 的 hash 都会变,后面缓存就会全部失效。
这套机制下,messages 数组的内部稳定性要被当成工程指标来管。传统上下文工程的几条纪律恰恰都在动这个稳定性。RAG 每轮根据 query 检索不同的片段拼进上下文,动态系统提示词注入当前时间、用户状态、当前 skill 的描述,hook 用完即拔,清理时重排消息顺序。这些动作都落在 prefix 的前段,每一次都让第一个变动点之后的 cache 全部作废。很多的渐进式披露(比如tool_search这种工具会直接修改tool内容),本意是节约上下文,但是很可能反而导致了更高的开销。
在上下文工程的世界观里这些都很合理,messages 每次都重建,调用之间相互独立,没有上一次需要保护。Hermes 工程里前提变了,messages 跨调用累积。一次用户交互在 agent loop 里要对应一长串 LLM 调用,每个工具调用插一个信息,工具返回插一个信息,messages 每轮都被追加。稳定的 prefix 让每一轮都复用前面所有轮的 cache。问题在于上面那些灵活设计对prefix不是改一次,而是逐轮都在改前段:每一轮都重新检索、重新注入,于是每一轮都要从变动点往后重新 prefill,前面所有轮算好的 KV 一次也用不上。上下文工程应对对话需求所需的调用频率远低于Agent的调用频率,因此很多提示词注入设计放到这里,反而会转成成本。
压缩丢掉的信息
context window 满了一定要压,不压会越界。压缩的危害是丢内容。
压缩的隐含假设是越旧的消息越没用,越靠前的工具结果越可以丢弃,后续的 assistant 推理通常已经把它们的关键信息消化进对话里了。问题是处在ReAct循环中的模型不清楚这个假设,它随时可能需要前面某一条工具结果的原始内容。用户让 agent 看三条新闻,agent 调工具看第一条、第二条、第三条,拿到第三条时第一条被截断,agent 综合三条信息时发现第一条只剩几个字,再去调一遍,这次拿到第一条时第二条又被截了,模型在丢失的信息上反复打转。极端情况下,被压掉的恰好是任务后续必需的上下文,agent 永远拿不到完整的信息,陷入死循环。不极端情况,那模型也会因为看不到原始内容产生幻觉导致任务最终跑偏。
这说明压缩不能是纯删。它要改变的是信息还在不在上下文里,而不是信息还存不存在。压缩之后必须给模型留一条找回压缩前内容的途径,把重要信息挪进 memory或者原始信息存成工具结果快照截断时只显示快照地址。压缩控制整体的token上限,召回途径让 agent 在需要时拿回原文。少了召回途径,压缩就是在乐观地假设模型不会再回头看。
压缩与缓存的对立
压缩和缓存存在结构性的对立。
压缩的假设是越旧越没用,所以从前面删。缓存的逻辑正好相反,prompt cache 复用的是前缀已经算好的 KV。压缩想动的位置,恰好是缓存最想保住的位置,两者对同一段 prefix 的处理方向是反的。
一个典型的有问题的设计:context 超过阈值,就压缩最前面那条还没压缩的工具结果。这套设计看着最直觉,把压缩和工具结果太多两个问题对应起来时几乎会第一个想到它。但它每一轮工具调用都在改 prefix 的前段,cache 永远命中不到,一边压一边把缓存收益清零。它同时把上一节那个信息丢失的坑踩满,刚拿到的信息下一轮就被压掉。
出路是把工具结果压缩从每轮事件改成偶发事件。设一个触发阈值,再设一个目标长度,目标要显著小于阈值,留出大余量。context 超过阈值才触发,一次把所有工具结果压到目标,接下来很多轮的追加都不会再碰到阈值,这段时间里 prefix 稳定、cache 命中、信息也不被反复挪动。余量越大,两次压缩之间能稳定复用缓存的窗口越长,但是触发压缩后丢失的工具内容信息就会越多。
全文压缩(把整个对话塞给便宜模型写摘要,context 换成 [system, summary, 最近几轮])在缓存和"反复丢信息"这两点上反而问题更少:它一次性失效整段 cache、之后长期稳定,而滑动窗口是每轮都失效一点、还反复丢信息。前提同样是给在摘要中补充足够的召回途径(工具原始输出进 memory 或给快照路径),否则它单次丢掉的总信息量比滑动窗口更大,只是丢得集中、不反复而已。
压缩为了不越界必须改 prefix,改 prefix 是缓存最怕的,信息删早了又会让 agent 拿不回上下文。压缩同时和缓存、和信息完整两边冲突,所以它不能一超阈值就压,也不能压得越狠越好。低频触发控制缓存的失效频率,可召回设计兜住信息不丢失,合理的设计在这两者之间取一个余量足够大的触发点。
总结
两节谈的是同一件事的两面。协议与流式是"过程"那条轴:调用还在发生,状态机就得在 token 流里做决策,信号必须早于完成。缓存与压缩是"序列"那条轴:调用不再相互独立,这一次的构建直接给下一次埋了雷。用上下文工程的思想把 LLM 调用重新当成"一个 messages 数组"很省心,但只要harness跑起来,很多问题就会在流式事件、缓存链、信息继承这些地方爆出来。
评论区