言午

Manus 之外:实战笔记(1)薛定谔的缓存

Image

几天前,团队的首席科学家 Peak 发了一篇《Context Engineering for AI Agents: Lessons from Building Manus》,小小地“秀”了一下肌肉。看到文章时,我着实惊到了,里面提到的很多技巧,要是在三个月前被我发出来,估计得被老板们拉出去 “枪毙”十分钟,现在竟然就这么水灵灵地公之于众。

不过,公司过于实在,文章里全是干货。“大道至简”有时反而会让没有亲身参与的朋友难以抓住重点(或者说,全文都是重点)。

于是我打算从 kv-cache 开始,结合在 Manus 的亲身经历,聊聊我在 ai agent 上所踩过的那些坑,也给大家更多视角去理解 Peak 的文章。

本文将以“探案故事”的形式,先完整呈现解决问题的曲折历程。对技术原理更感兴趣的读者,在故事结束后,准备了关于“KV缓存”的技术深潜,彻底讲透它背后的工作机制与成本影响。

第一案:薛定谔的缓存

难道缓存命中率和卷积神经网络一样,也是个无法预测的 黑盒 吗?

这个问题,在 Manus 上线不久后,像 幽灵 一样缠上了我们。同样模型、同样请求,服务商 A 的缓存命中率几乎是 B 的两倍,账单上的成本差大到肉疼。当时模型资源紧张,我们又不得不两家混用。如果弃用 B,就意味着要拒绝大量客户,这对一个正在高速增长的产品来说,是不可接受的。

排查工作随即展开,但第一步就傻眼了。我们尝试从原始请求分析,发现缓存命中情况毫无规律,就像个 精神病!它时而正常,时而出其不意地骤降,接着又莫名-其妙地恢复。更加要命的是,每当我们自己手动构造请求去复现,缓存却总能稳定命中。

我只能再次扎进日志,对出现波动前后的请求仔细检查,结果让我更加 绝望:请求没有任何问题,Prompt 的历史部分能严丝合缝地对上。我们甚至在实际发起 HTTP 请求的地方开了日志,把原始请求体打印出来,依旧没发现任何问题。

就在我们打算把这当做一个无法解决的问题,当做交给云厂商的“玄学税”时,服务商 B 的工程师建议我们开启厂商侧的云日志,抓取服务端数据。正是这个无意的建议,让事情出现了转机。服务端日志显示,我们发过去的请求,并不是严格的前缀匹配,有些地方不一样! 这直接导致了缓存无法命中。

这个发现和我们本地的日志结论相互矛盾!我把服务端有差异的请求部分仔细对比,最终将疑点锁定在 function_call 的 parameters 参数上——LLM 生成的函数调用参数,有概率会出现键值对的顺序变化。我脑子里立马闪过一个念头:Go 语言里 map 的遍历顺序是随机的! 我一下就从椅子上跳起来了,感觉抓住了真凶。

但转念一想,逻辑又对不上了。如果 map 遍历是随机的,那为什么服务商 A 那边是正常的?难道他们更“智能”,对我们的请求做了内部排序?

我还是先写了个单元测试。结果发现,同一个 map,无论怎么序列化,输出的字符串顺序都是固定的。看到这个结果,我几乎要开香槟了——证据确凿! 问题显然不在我们这边。我立刻开始整理材料,准备去和厂商 B 好好“理论”一番。

就在我自信满满,为了把证据链做得更扎实,而在本地做最后一次网络抓包时,诡异的事情出现了:程序中打印的日志,和网络抓包工具抓到的实际 HTTP 请求内容,竟然不一样了!

我愣在工位上,难道是 见鬼了? 刚才还铁证如山的结论,瞬间就被推翻了。

既然问题被锁定在 HTTP 请求发出的最后一刻,那就只能逆流而上。我把请求体的二进制数据直接打印出来,随机性在这里清晰地暴露了。问题又绕回了 map 序列化。没办法,只好祭出单步调试大法,肉眼盯着代码,一步步往下走。

终于,在对两个 SDK 的深层嵌套调用进行逐一审判后,案件彻底告破:

Go 语言的 map 遍历确实是随机的,但在其标准库进行 JSON 序列化时,它默认会对 map 的键(key)做一次排序! 这就是为什么我的单元测试结果总是固定的。服务商 A 使用了 Go 原生序列化,天然规避了此问题。而服务商 B,用了自己实现的、且没有对 key 排序的序列化逻辑,结结实实地踩进了这个大坑!

Image

这一下,之前所有的矛盾都解释得通了!

  • • 为什么这个 Bug 最初根本复现不了?
    • • 因为它的触发条件极其刁钻:必须使用 Go SDK,必须使用 function_call,并且 function_call 的参数必须大于一个——单个参数无所谓顺序——所有条件同时满足,才有概率触发这个“乱序”。我们最初手动复现时,根本就没构造出这么复杂的场景!
  • • 为什么本地日志永远是“对的”?
    • • 因为我们打印日志时,调用的正是 Go 的原生序列化,key 被自动排序,所以日志看起来永远完美无缺。而真正发起 HTTP 请求时,走的却是服务商 B SDK 内部那套“我行我素”的序列化逻辑,这才导致了网络抓包和日志不一致的“见鬼”现象!

归根结底,一个由第三方 SDK 自行实现的、缺少了“默认排序”的序列化方法,在我们高速增长的业务背后,上了昂贵的一课。

【技术深潜 #1】解密 KV 缓存:从模型核心机制到服务成本优化

在我们的“探案”故事中,一个 JSON 字段的微小顺序变化,为什么会让模型服务商的账单天差地别?要回答这个问题,我们必须理解 LLM 推理的两个层面:首先是模型内部生成每个字的核心工作机制,其次是服务商为了优化成本而实现的跨请求缓存策略。我们案件中的“缓存”,指的就是后者。

核心推理机制:动态的 KV 状态

大语言模型(LLM)是**自回归(Autoregressive)**的,这意味着它是一个字一个字地生成内容的。为了生成第 N+1 个字,它必须参考前面所有 N 个字的信息。

如果每次生成新字,都把全部 N 个字重新在模型里计算一遍,这个计算量是灾难性的。因此,Transformer 架构采用了一种极为高效的“状态传递”机制。这个状态,就是我们常说的 KV (Key-Value) 对。

这个过程分为两步:

  1. 1. 预填充 (Prefill) — 计算初始状态
    当模型收到你的 Prompt 时,它会并行处理所有输入的 Token,并为每一个 Token 生成一组 K-V 向量。这些 K-V 向量可以被看作是整个输入 Prompt 在模型内部的“记忆快照”或“初始状态”。这是一个计算密集型的步骤,因为它需要一次性处理整个输入。
  2. Image
    Image
  3. 2. 解码 (Decoding) — 增量更新状态并生成
    预填充完成后,模型进入逐字生成阶段。此时,它不再需要原始的 Prompt 文本。在生成每一个新 Token 时,它只做两件事:

    这个不断增长的 K-V 向量列表,就是模型在单次推理中的运行时内存。它是模型高效运行的内建机制,确保了在生成长序列时,后续每个 Token 的生成成本是固定的,而不是随着序列增长而爆炸。

  • • 利用现有状态:模型为即将生成的下一个新 Token 计算其 Query 向量,并让这个 Query 向量与内存中所有历史 Token 的 Key-Value 向量进行注意力计算,从而预测出下一个最可能的 Token。
  • • 更新状态:只为这个刚刚生成的新 Token 计算出其自身的 K-V 向量,然后将其追加到内存中 K-V 向量列表的末尾,供下一次生成使用。
  • Image
    Image
  • 服务层优化:跨请求的前缀缓存

    现在,我们终于可以谈论我们案件中真正的“缓存”了。这层缓存,是 LLM 服务商为了优化成本,在模型自身机制之上额外实现的功能,我们称之为前缀缓存(Prefix Cache)。

    它的逻辑非常直接,是核心机制的自然延伸:

    既然为 Prompt 计算初始“KV 状态”的预填充步骤非常昂贵,那么如果很多请求的开头部分都完全一样,我们能不能只计算一次,然后把这份初始状态存起来,供后续所有匹配的请求重复使用呢?

    答案是肯定的。这就是前缀缓存的工作原理:

    1. 1. 前缀匹配:当服务商收到一个新请求,它会先将 Prompt 的 Token 序列与已缓存的“KV 状态”记录进行前缀匹配。
    2. 2. 缓存命中:如果发现一个请求的 Token 序列前缀,与缓存中某条记录完全对应,服务商就会直接将这份预先计算好的“KV 状态”加载到内存中。
    3. 3. 跳过预填充:模型直接跳过了对这部分共享前缀的昂贵预填充计算,只对 Prompt 中超出前缀的剩余部分进行增量处理,然后开始解码。
    4. Image

    这在多轮对话或 RAG 等场景下能节省巨量的成本,因为这些场景下的大部分请求,都共享着完全相同的系统指令和历史对话。

    结论:缓存的命门在于精确的 Token 序列

    现在,整个逻辑链条完美闭合了。

    服务商提供的前缀缓存,其全部价值都建立在一个精确且不变的 Token 序列前缀之上。

    当你的 JSON 字符串仅仅因为键的顺序变化,从 {"a": 1, "b": 2} 变为 {"b": 2, "a": 1} 时,分词器(Tokenizer)会生成一个全新的 Token 序列。这个新序列与服务商缓存中存储的任何记录都无法匹配。

    于是,前缀缓存被宣告无效。服务商的系统无法利用这份本可复用的“历史记忆”,不得不退回到最原始、最昂贵的方式——对你的整个 Prompt 重新进行一次完整的预填充,为它从头计算一份全新的初始 KV 状态。

    这就是为什么,一个由第三方 SDK 序列化方法导致的微小差异,会在我们的账单上掀起一场价值不菲的风暴。我们追求输入的确定性,本质上是在守护 LLM 服务高效推理的根基。

    经验总结 #1:序列化的绝对确定性是缓存的生命线

    这次代价不菲的排查给我们上了最深刻的一课:大语言模型服务的前缀缓存(Prefix Cache)是极其脆弱的,它依赖于请求前缀在 Token 化后 的完全一致。任何一个字符、一个空格甚至一个键值对顺序的微小变化,都可能导致 Token 序列不同,从而让缓存从差异点开始全盘失效。

    这要求我们必须对“确定性”有 近乎偏执的追求。当你使用任何第三方库或 SDK(尤其是涉及网络请求和数据序列化的部分)时,绝不能理所当然地认为其行为与原生实现完全一致。必须深入其源码,或通过严谨的测试,挖到根上,彻底搞清楚它是如何实现数据序列化的。一个被忽略的实现细节,就可能在生产环境中演变成一场吞噬利润的“完美风暴”。


    下期预告:当"黑盒"遇上"路由"

    薛定谔的缓存只是我们在 Manus 优化路上遇到的第一个"幽灵"。解决了序列化的确定性问题后,我们以为可以高枕无忧,却没想到"坑"是一个接着一个。

    在下一篇文章中,我将分享一个更加离奇的案例:为什么服务商的工程师会建议我们"故意破坏"缓存一致性?在 System Prompt 末尾加上随机 ID,反而能提升缓存命中率? 。

    我们将深入探讨事件经过和自动缓存的实现。

    敬请期待《Manus 之外:实战笔记》(2)

    * 本文配图和部分内容由 AI 生成