<aside> 📘
Series:
Context Engineering for AI Agents: Lessons from Building Manus
</aside>
几天前,团队的首席科学家 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 排序的序列化逻辑,结结实实地踩进了这个大坑!