本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。

第 4 章的前三节分别解决了三个问题:上下文为什么会爆炸、有哪些压缩策略、跨会话的事实怎么存和取。零件都在手上了,但把它们接进 Agent 循环,和把它们并排放着,是两件事。

课程第 16 节没有单独讲义,只给了一个可运行的整合示例。这个示例值得单独看,因为接错的地方不会报错,只会悄悄失效。

三个接入点

一个完整的 v2 循环有三个位置需要改动。

调模型之前,要先召回记忆并把结果注入提示。历史累积到阈值时,要先压缩再调模型。会话结束时,还要把这一轮值得长期记住的事实提炼出来写回记忆库。

这三个动作插入的位置不同,失败后的表现也不同。召回失败,用户会觉得 Agent 记性差;压缩失败,跑几十轮后撞窗口;提炼失败,下一次会话仍然从零开始。

1
2
3
4
5
6
7
8
9
10
11
12
def chat(self, user_input: str) -> str:
self.messages.append({"role": "user", "content": user_input})

recalled = self.memory.search(user_input, k=3)
relevant = [(score, e) for score, e in recalled if score >= self.recall_threshold]
injected = self._build_injected(relevant)

if self.context_manager.should_compress(self.messages):
self.messages = self.context_manager.compress(self.messages)

answer = self.llm(self.messages + injected)
return answer

骨架不复杂,难的是三个细节。

召回阈值不能照搬

向量检索最容易被抄错的参数是阈值。很多资料给的参考值是相似度 0.5 以上算相关、0.7 以上算很相关,照抄进代码,结果一条也召不回。

原因在 embedding 模型。不同模型输出的余弦分布差异很大,课程用的 embedding 模型在真实语料上的同主题相似度通常落在 0.18 到 0.41 之间,0.5 这个阈值已经高于分布上沿。代码照常运行,relevant 永远是空列表,注入块永远为空,看起来一切正常。

课程的修法是把阈值收敛成一个常量,按实际模型的分布校准,并把召回结果打印出来:

1
2
3
4
if relevant:
print(f"召回 {len(relevant)} 条(阈值 {self.recall_threshold})")
elif recalled:
print(f"无召回,最高分 {recalled[0][0]:.3f} 低于阈值")

打印这一行不是调试残留,而是校准手段。没有它就看不到阈值定得对不对,也看不到是检索失效还是记忆里根本没有这条事实。

压缩之后必须回写

第二个坑出在压缩的作用域。

压缩的直觉写法是:本轮调模型前压一遍,传给模型的 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。差别在于工具集从计算器和天气查询换成了读写文件与执行命令,而循环、压缩、记忆三者的接法完全一样。