导学阶段-Agent v2 整合
本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。
第 4 章的前三节分别解决了三个问题:上下文为什么会爆炸、有哪些压缩策略、跨会话的事实怎么存和取。零件都在手上了,但把它们接进 Agent 循环,和把它们并排放着,是两件事。
课程第 16 节没有单独讲义,只给了一个可运行的整合示例。这个示例值得单独看,因为接错的地方不会报错,只会悄悄失效。
三个接入点
一个完整的 v2 循环有三个位置需要改动。
调模型之前,要先召回记忆并把结果注入提示。历史累积到阈值时,要先压缩再调模型。会话结束时,还要把这一轮值得长期记住的事实提炼出来写回记忆库。
这三个动作插入的位置不同,失败后的表现也不同。召回失败,用户会觉得 Agent 记性差;压缩失败,跑几十轮后撞窗口;提炼失败,下一次会话仍然从零开始。
1 | def chat(self, user_input: str) -> str: |
骨架不复杂,难的是三个细节。
召回阈值不能照搬
向量检索最容易被抄错的参数是阈值。很多资料给的参考值是相似度 0.5 以上算相关、0.7 以上算很相关,照抄进代码,结果一条也召不回。
原因在 embedding 模型。不同模型输出的余弦分布差异很大,课程用的 embedding 模型在真实语料上的同主题相似度通常落在 0.18 到 0.41 之间,0.5 这个阈值已经高于分布上沿。代码照常运行,relevant 永远是空列表,注入块永远为空,看起来一切正常。
课程的修法是把阈值收敛成一个常量,按实际模型的分布校准,并把召回结果打印出来:
1 | if relevant: |
打印这一行不是调试残留,而是校准手段。没有它就看不到阈值定得对不对,也看不到是检索失效还是记忆里根本没有这条事实。
压缩之后必须回写
第二个坑出在压缩的作用域。
压缩的直觉写法是:本轮调模型前压一遍,传给模型的 messages 变短了,任务就完成了。但 Agent 的历史是持续累积的,如果压完不回写,下一轮读到的还是那份没压缩过的历史,于是每一轮都要重新压缩一次,摘要调用重复付费,曲线照旧上涨。
正确的做法是压缩结果回写 self.messages,让历史真的变短。课程示例在 12 轮任务里,压缩在第 11 轮触发,触发后 prompt token 明显下降,这条下降的曲线就是回写生效的证据。
回写之后会冒出一个新问题,这也是第三个细节。
事实槽要做并集
早期事实是靠扫描用户消息抽出来的。历史一旦被压缩,第 2 轮那句”我对花粉过敏”就不在历史里了,下一次压缩时抽不到它,事实槽随之清空。
现象是:第一次压缩后记忆还在,第二次压缩后过敏约束消失了,Agent 开始推荐户外行程。
修法是在压缩时对事实槽做并集,把上一次抽出的事实和这一次的合在一起,而不是覆盖。课程示例里把注入块的三个前缀单独标记,压缩前先剔除旧块再重建,避免重复叠加,同时保留已有事实。
这类问题不会在单元测试里暴露,只有在多轮连续对话里才会出现。写记忆系统时,凡是”每轮重新计算”的中间结果,都要问一句:这个结果如果丢了,哪些信息会跟着消失。
需要一个对照组
判断 v2 是否真的有效,不能只看它跑得通。
课程示例加了 v1 对照组:同样的任务、同样的轮数,不接记忆也不接压缩。两组并行跑 12 轮,逐轮记录 prompt token 与 total token,最后给累计对比。
这一步的价值在于把”感觉省了”变成可测的数。v1 的曲线单调上升,v2 在压缩触发后出现断崖,两条曲线分叉的位置,就是压缩真正生效的位置。同时还能观察同一个约束在两组里的表现:v1 因为早期消息被稀释,往往答不对;v2 因为事实槽一直保留,答案稳定。
对照组还有一个副作用:它把系统复杂度显式化了。v2 比 v1 多两次组件调用、多一次摘要调用,收益和成本同框出现,选型才谈得上理性。
组件与循环的分工
把这三件事做完,可以看清 ContextManager、Memory 与 Agent 的分工。
ContextManager 管”这一轮该看到多少历史”,Memory 管”跨会话该记住什么”,Agent 管”什么时候调用它们”。前两者都是无状态的工具类,状态全部挂在 Agent 实例上,这也解释了为什么多轮对话必须复用同一个实例。
第 5 章会用这套结构做一个真正的 Coding Agent。差别在于工具集从计算器和天气查询换成了读写文件与执行命令,而循环、压缩、记忆三者的接法完全一样。


