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

会话一关,Agent 知道的东西就没了。下次开新会话,模型对上次聊过什么一无所知。模型没有状态,每一轮收到的 messages 列表由程序重新拼好,对话结束,这份列表随之释放。

三种记忆,三种寿命

记忆类型 长什么样 存活时间 谁管
短期记忆 本次对话的 messages 列表 对话结束即丢弃 Agent 循环
长期记忆 持久化的事实库 跨会话存活 Memory
工作记忆 当前任务用到的精挑片段 用完即弃 ContextManager

短期记忆就是本次对话的 messages 列表,Agent 循环负责维护。长期记忆存放跨会话仍然成立的内容,比如用户偏好、历史决策、项目规范。工作记忆介于两者之间,是当前任务真正用得上的片段,例如正在修改的那个函数、上一次工具调用的结果,任务做完就没有价值。

工业 Coding Agent 三种都在用。会话历史是短期记忆,当前任务相关的代码片段与上一步工具输出是工作记忆,用户的常用风格、项目的代码规范、装好的 Skills 是长期记忆。

把三者混为一谈,会把两个问题当成一个:上下文一长回答变差,是短期记忆超限,靠压缩策略解决;换一个会话就失忆,是缺长期记忆,要靠检索。

该存什么,不该存什么

一个常见的误判是长期记忆等于把所有历史对话存进数据库。存下来容易,用起来难。会话记录里大部分内容是”好的””继续”这类没有长期价值的回应,真正该留住的是少数几条事实。课程给出的判断很直接:用户偏好、硬约束、重要事实值得存,一次性问答不值得存。

还有一条容易忽略的存储纪律:每条记录只放一个事实。”用户对花粉过敏”和”用户每天预算 500 元”分成两条,不要合成”用户过敏、预算 500、喜欢博物馆”。合成之后,召回要么整条进来、要么整条不进来,问的是过敏,预算约束会被一起带进上下文,或者一起丢掉。

长期记忆的价值在第二次会话才显现。第一次会话用户说”我对花粉过敏”,第二次会话问”帮我安排周末户外”。没有记忆,模型推荐花海公园;有记忆,它先召回”对花粉过敏”这条事实,再避开花季。两次推荐的分叉点在召回这一层,与模型无关。

关键词找不到改写过的说法

关键词检索 向量检索
判断依据 问题和事实有多少相同的词 两个向量方向有多接近
典型失败 查”我有什么过敏症状”找不到”我对花粉敏感” 依赖 embedding 模型质量
成本 零依赖,毫秒级 需要调用 embedding 接口

“我有什么过敏症状”和”我对花粉敏感”字面上没有一个实词重合,基于词频的 BM25 一类方法算出来接近零分,召回直接失败。向量检索换了一套判断:把句子编码成一个固定长度的浮点数组,语义相近的句子落在这个空间里相近的位置,再比较两个向量的夹角。

embedding 模型和 chat 模型是两回事。它不对话,只负责编码,接口挂在同一个 base_url 和 api_key 下的 /embeddings 端点。向量长度由模型写死,text-embedding-3-small 是 1536 维,3-large 是 3072 维,BGE-base 与 M3E-base 是 768 维,课程演示脚本使用的 hunyuan-embedding 是 1024 或 1536 维,取决于版本。1536 维的 float32 向量占 6KB,十万条事实就是 600MB。

十行代码的 Memory

把编码、存储、召回串起来,一个能用的记忆类只需要几个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import numpy as np
from openai import OpenAI

client = OpenAI(base_url=BASE_URL, api_key=API_KEY)

def embed(text: str) -> np.ndarray:
resp = client.embeddings.create(model="hunyuan-embedding", input=text)
return np.array(resp.data[0].embedding, dtype=np.float32)

def cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))

class Memory:
def __init__(self) -> None:
self.entries: list[tuple[str, np.ndarray]] = []

def add(self, text: str) -> None:
self.entries.append((text, embed(text)))

def search(self, query: str, k: int = 3) -> list[tuple[float, str]]:
q = embed(query)
scored = [(cosine(q, v), t) for t, v in self.entries]
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:k]

add 做两件事:编码,存成文本与向量的一对。search 也做两件事:编码查询,逐条算余弦后排序取前 k。核心逻辑就这些,持久化和元数据都是外围加工。

余弦值的范围是 [-1, 1],越接近 1 越相似。课程把阈值统一定义成 RECALL_THRESHOLD = 0.35,低于它的召回结果不注入 prompt。这个数字必须按 embedding 模型校准,不能照抄流传的 0.6 或 0.7:课程使用的 hunyuan-embedding 上,同主题文本实测落在 0.18 到 0.41,完全无关的落在 0.08 到 0.23。0.35 取在无关项上沿之上、同主题上沿之下。换一个 embedding 模型,整条分数分布会平移,阈值要重新抽样确定。

读在对话前,写在对话后

记忆和 Agent 的协作是一个读写闭环。读的环节在每轮对话开始前:用当前问题去记忆库里检索,相似度低于阈值的丢弃,剩下的拼进 system prompt。

1
2
3
4
5
RECALL_THRESHOLD = 0.35   # 按 embedding 模型校准,课程所用模型同主题实测 0.18 到 0.41

recalled = memory.search(user_input, k=3)
facts = [t for score, t in recalled if score >= RECALL_THRESHOLD]
system = base_system_prompt + "\n\n[相关长期事实]\n" + "\n".join(facts)

模型本身没有变化,它只是看到了更多上下文。写的环节在对话结束后:用 LLM 从本轮对话里提炼值得长期记的事实,每行一条,逐条写入记忆库。提炼提示里要明确说清不要把一次性问答存进去,否则”刚才说了什么”这类临时内容会污染记忆库。

与 ContextManager 的分工也因此清晰:Memory 决定哪些长期事实进入 system prompt,ContextManager 决定本轮 messages 怎么压缩。压缩是有损的,被压掉的内容当轮就找不回来;事实库是持久的,这一轮没召回到,下一轮换个问法还有机会。

最小实现离生产还有距离。numpy 逐条算余弦是 O(n),上万条事实之后每轮召回都变慢,换成 FAISS、Qdrant 这类带 ANN 索引的向量库可以把检索降到近似对数级。同一段文本重复编码浪费成本,可以按文本 hash 缓存。用户偏好会随时间变化,老记忆应当衰减权重。多用户场景下每条事实还要绑定 user_id,否则张三的花粉过敏会被召回给李四。

这些技巧最终都落在两个动作上:把值得留下的事实写成一条可检索的记录,需要时按语义取回来。模型没有因此长出新能力,它只是每一轮都看到了更完整的那段上下文。

上下文和工具凑齐之后,下一个问题是如何把这种能力收窄到写代码。下一节从 Coding Agent 的五个工作阶段说起。