给 Agent 装上记忆:多轮对话的历史管理——token 预算、按轮裁剪与滚雪球摘要

给 Agent 装上记忆:多轮对话的历史管理------token 预算、按轮裁剪与滚雪球摘要

摘要:承接前两篇的手写 ReAct Agent 与 Function Calling 重构,本文给它加上多轮对话能力------历史怎么累积、token 怎么数、超预算了按什么边界裁剪、被裁掉的轮怎么用摘要救回来(滚雪球合并)。这是 Context Engineering 的核心一课:什么信息、什么时机进入上下文。

背景

上篇结尾留了个钩子:Function Calling 版 Agent 工具齐了、错误回传稳了,但它是一条"金鱼"------run(question) 里创建 messages,答完即弃。你问它"上海今天天气怎么样",它答得很好;紧接着问"那北京呢?",它一脸茫然:"那"指什么,它不知道

单问变多轮,表面上是小改动。但历史一旦真正累积起来,会撞两堵墙:

  1. 上下文窗口上限:超过模型窗口,API 直接报错;
  2. 成本 :上下文按 token 计费,且每轮全量重发。假设每轮新增 500 token、聊 50 轮:第 50 轮单次请求就要发约 2.5 万 token,全程累计约 64 万 token------账单随轮数平方级增长

所以多轮 Agent 的核心问题不是"怎么把历史带上"(一个 list 的事),而是"怎么管理历史 ":什么信息留下、什么压缩、什么丢弃、在什么时机做这些决定。这就是行业共识里取代 Prompt Engineering 的那个词------Context Engineering:能决定"什么信息在什么时机进入上下文窗口"的人,比会写漂亮提示词的人值钱。

问题:历史管理是三个递进的子问题

把"管理历史"拆开:

  1. 测量:当前历史值多少 token?不算清成本就动手裁,等于闭眼做手术;
  2. 裁剪:超预算了,从哪里切?切错位置,API 直接 400;
  3. 挽救:被裁掉的信息怎么办?直接扔,用户十轮前说的名字就没了。

对应的三个实现件:token 计数器、按轮裁剪器、摘要压缩器。

实现

1. 从"金鱼"到多轮:表面只改三处

先说好消息:单问变多轮,Agent 核心循环一行不用动,改动只有三处:

python 复制代码
# week5: def run(question): ------ messages 在函数里创建,用完即弃
# week6: 历史由调用方持有,函数接收并返回它
def chat_turn(history: list[dict], user_input: str) -> tuple[str, list[dict]]:
    messages = [to_dict(m) for m in history]         # 改动①:SDK 对象 → dict 规范化
    messages.append({"role": "user", "content": user_input})
    messages = manage_history(messages, budget=...)  # 改动②:调模型之前做历史管理
    # ......以下 FC 内循环与 week5 逐行相同
    return answer, messages                          # 改动③:历史随答案一起返回

三处改动各自的原因:

  • to_dict() 规范化:历史接下来要被裁剪、打印体检、(将来)序列化存盘,这些操作都需要普通 dict;SDK 消息对象是半黑盒;
  • manage_history() 放在调模型之前:预算约束的是即将发出的请求体积,等响应回来再裁,钱已经花了;
  • 历史由调用方持有:Agent 本身不持有状态,为多会话并发留路。

效果立竿见影------指代测试:

lua 复制代码
你> 上海今天天气怎么样?
助手> 上海今天天气:多云,28°C。......

你> 那北京呢?
===== 第 1/8 步 =====
▶ 调用 get_weather({"date": "今天", "location": "北京"})
----- 工具返回 -----
北京 今天 晴 25°C
助手> 北京今天天气:晴,25°C。比上海凉快一些,......

"那北京呢"五个字里,"那"指天气、"北京"是新地点------全靠历史接住。注意最后一句"比上海凉快一些":模型不仅接住了指代,还主动做了跨轮对比。这是单问版不可能出现的行为。

2. token 计数:先测量,再动手

精确计数比想象中难:tokenizer 依赖具体模型,GPT-4o 和 DeepSeek 词表不同,同一句话各家数出来不一样。生产做法二选一:tiktoken 库(OpenAI 系精确,跨厂商也只是更准的近似),或事后读 API 响应的 usage 字段(精确但滞后,只能指导下一轮)。

教学版用经验近似,零依赖:

python 复制代码
def estimate_tokens(text: str) -> int:
    if not text:
        return 0
    cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
    return int(cjk * 0.75 + (len(text) - cjk) / 4) + 1

中文 1 字 ≈ 0.75 token,英文 4 字符 ≈ 1 token。误差 10-20%,但不影响"该不该裁"的决策------你要判断的是"5000 是否大于 4000",不是"5000 还是 4800"。

一个必踩的坑藏在消息级:assistant 请求工具时 content 为 None,但 tool_calls 里的函数名和参数 JSON 同样计费。漏算它们会系统性低估成本:

python 复制代码
def message_tokens(m: dict) -> int:
    total = estimate_tokens(m.get("content") or "") + 4   # +4:role 等结构开销
    for tc in m.get("tool_calls") or []:
        total += estimate_tokens(tc["function"]["name"]) \
               + estimate_tokens(tc["function"]["arguments"]) + 4
    return total

3. 按轮裁剪:切分边界的铁律

超预算了,从哪里切?先定义"轮":

sql 复制代码
一轮 = 一条 user 消息 + 其后到下一条 user 消息之前的所有 assistant / tool 消息

为什么边界必须是轮的起点?回忆上篇的协议:assistant(tool_calls)role="tool" 消息靠 tool_call_id 配对,这是 API 的硬性要求。从中间劈开历史、拆散任何一半配对,API 直接 400。而轮的起点(user 消息)天然是配对安全的切分位置------轮内所有 tool 消息的前面必有它们的 assistant。

python 复制代码
def split_turns(messages):
    system_msgs = [m for m in messages if m["role"] == "system"]  # 永不参与裁剪
    rest = [m for m in messages if m["role"] != "system"]
    turns, current = [], []
    for m in rest:
        if m["role"] == "user" and current:
            turns.append(current)
            current = []
        current.append(m)
    if current:
        turns.append(current)
    return system_msgs, turns

两种裁剪粒度:

策略 实现 特点
滑动窗口 trim_by_turns(n) 保留最近 n 轮 5 行实现零成本,但"3 轮前的关键事实"和"3 轮前的废话"被同等对待
预算裁剪 trim_by_budget(b) 从最老的轮开始扔,扔到装得下 粒度是真实成本------轮数是拍脑袋的代理指标,token 才是账单单位

外加一条铁律:最新一轮无条件保留。宁可这一轮超预算多花点钱,也不能把用户刚说的话裁掉------那是事故级错误。实现上就是"从最新往老装轮时,第一轮无条件收下"。

4. 摘要压缩:被裁掉的轮不该无声消失

丢弃策略有个隐患:用户第 1 轮说"记住我的代号是 42",聊到第 10 轮触发裁剪,第 1 轮出局------Agent 失忆了

解法是把出局的轮压缩成一条摘要。摘要指令本身就是 Context Engineering------保什么、扔什么必须显式写出来:

复制代码
只保留四类信息:
① 用户的整体目标与意图
② 关键事实(称呼、数字、约束、偏好)
③ 已做出的决定及结果
④ 尚未解决的问题
丢弃寒暄、重复和工具原始输出的细节。

两个容易漏掉的设计:

滚雪球合并 。如果每次只摘要"这次新出局的轮",那么第 2 次溢出时,第 1 次生成的旧摘要会在替换时被丢掉------记忆每溢出一次就失忆一次。正确做法是把旧摘要一并送进摘要器合并:

python 复制代码
def summarize_turns(dropped_turns, old_summary_msg, summarize_fn):
    parts = []
    if old_summary_msg:
        parts.append("【此前的摘要,请把要点合并进新摘要】\n" + ...)
    parts.append(render_turns_text(dropped_turns))
    return summarize_fn("\n\n".join(parts))

工具输出先截断再送摘要。工具返回值是上下文里"体积最大、时效最短"的成分------渲染进摘要文本时只保留前 300 字。八成的体积贡献不到一成的记忆价值,这笔账很好算。

摘要消息以 role="system" 存放(排在系统提示词之后):它和人格一样属于"常驻背景知识",不参与轮的切分与裁剪。识别旧摘要靠内容前缀约定((更早对话的摘要)),不引入协议外字段。

5. 什么信息、什么时机进入上下文

这是 Context Engineering 的心智模型,也是整篇的收束:

信息 进上下文的方式 时机 理由
系统提示词 常驻,永不裁剪 会话开始 每次决策都依赖;丢了人格漂移
工具 Schema 常驻,每次随发 每次调用 模型"知道有哪些工具"全靠它
用户当前问题 常驻 刚发生 被裁掉 = 事故
最近几轮原文 常驻(预算内) 自然累积 指代消解主要靠近几轮原文
更早的对话 压缩成摘要后进入 溢出发生时 原文全留太贵
历史摘要 常驻,替换式更新 每次溢出滚雪球 防止反复失忆
工具返回值 短暂停留 当轮保留,出局时最先牺牲 体积最大、时效最短

被裁危险度排序(裁剪器实现顺序的依据):本轮用户问题(事故)> 早前关键事实(失忆)> 工具原始输出(基本无感)

运行效果

把预算压到 150 token(演示用;正常对话给 4000+),完整触发"裁剪 → 摘要 → 滚雪球 → 记忆验证":

css 复制代码
你> 记住我的代号是 42
助手> 好的,我记住了,你的代号是 42。有什么需要帮忙的吗?

你> /budget 150

你> 上海今天天气怎么样?
助手> 上海今天天气:多云,28°C。......

你> 那北京呢?
[历史管理] 总量 159 tok 超出预算 150:出局 1 轮(约 33 tok)已压缩为摘要(约 53 tok);保留 2 轮
助手> 北京今天天气:晴,25°C。比上海凉一点,......

你> 我的代号是多少?
[历史管理] 总量 251 tok 超出预算 150:出局 2 轮(约 151 tok)已压缩为摘要(约 86 tok);保留 1 轮
助手> 你的代号是 42。

两次溢出,各一次摘要,注意第二次发生了什么:

  1. 第 1 次溢出:说"代号 42"的那轮出局,被压缩进摘要;
  2. 第 2 次溢出:上海、北京两个天气轮出局,旧摘要滚雪球合并进了新摘要------所以代号没有丢;
  3. 最后一问"我的代号是多少":此时原文早已不在上下文里,答案完全来自那条 140 字的摘要

/history 体检最终状态:

sql 复制代码
共 4 条消息,估算 143 token:
   0 system       45 字  你是一个借助工具解决问题的助手。......
   1 system      140 字  (更早对话的摘要)- 用户目标:查询天气...... - 关键事实:用户......
   2 user          8 字  我的代号是多少?
   3 assistant    13 字  你的代号是 42。

四条消息装下了整场对话的"要点 + 当前轮"------这就是历史管理的全部意义。

踩坑记录

按痛的程度排序:

  1. 演示预算拍脑袋 。第一次演示设了 300 token 预算,结果聊了 4 轮总共才 242 token------裁剪根本没触发。教训:中文短对话比想象中便宜得多,调预算前先看 /history 的实际估算。"先测量再动手"这条原则,自己演示时也会忘。
  2. 裁剪拆散 tool_call_id 配对 = API 400 。按"消息条数"从头截断而不是按轮切,assistant(tool_calls)tool 消息极易被劈开。轮的起点是唯一安全的切分边界。
  3. 摘要不做滚雪球 = 每溢出一次失忆一次。只摘要新出局的轮,旧摘要会在替换时被丢掉。
  4. token 计数漏算 tool_calls。assistant 请求工具时 content 为 None,但函数名和参数 JSON 照样计费,漏算会系统性低估成本。
  5. 摘要指令不写"保什么"等于没写。"把历史压缩一下"这种模糊指令会把代号、目标一起压没------四要素必须显式列出。
  6. 单轮就超预算时,选择放行而不是报错。宁可这轮贵一点,也不能裁掉用户刚说的话;真超窗口上限,让 API 的报错暴露问题,而不是自己先制造一个"答非所问"的静默事故。

总结

三种策略的取舍,一张表收束:

策略 成本 信息损失 适用
丢弃(窗口/预算) 出局轮彻底消失 闲聊、低事实密度对话
摘要压缩 每次溢出一次 API 调用 细节丢、要点留 长任务(名字/目标/决定必须记住)
原样保留 每 token 每轮重复计费 只配给最近几轮------模型对原文的记忆远好于摘要

面试题"多轮对话的上下文怎么管理?超长怎么办?",现在的回答可以这么展开:先测量(token 计数,近似就够,决策只需要量级);超预算按完整轮裁剪(轮 = user 消息到下一条 user 之前,tool_call_id 配对不能拆散);出局的轮用摘要挽救,四要素显式声明保什么,旧摘要滚雪球合并防反复失忆;最新一轮和系统提示词永远保留;工具原始输出是最先牺牲的对象。管住了这些,Agent 才从 Demo 走向可用。

下一步:RAG。记忆管住了,但模型还是不知道你公司的知识------文档解析、分块策略、向量检索、引用溯源,这是"答得对且可追溯"的那条链路。


相关推荐
掰头战士1 小时前
AgentLoop: 从 while(true) 到生产级循环
typescript·llm·agent
张忠琳1 小时前
【deepseek-harness】DeepSeek Harness Agent Loop 模块深度架构分析之二
ai·agent·deepseek·harness·dsh
用户8082598666871 小时前
给 Agent 接上知识库:RAG 检索链路——分块策略、混合检索与重排
agent
山间小僧1 小时前
「AI学习笔记」Agent Memory(一)会话内记忆
aigc·agent·vibecoding
染指11103 小时前
113.Agent-LangChain核心组件-大模型Short-term_memory短期记忆和PostgreSQL记忆存储
人工智能·langchain·agent·agents
用户283209679374 小时前
Function Calling 只是开始:Agent 工具系统到底难在哪
agent
AaronLou4 小时前
Effect 的类型报错怎么读:认全 `Effect<A, E, R>` 这三个位置,一半报错自己就解释了
agent
AaronLou4 小时前
TypeScript 后端那些你自己手写的样板,Effect 一次性收掉
agent
TunerT_TQ4 小时前
智能体评测的哲学——当“跑分”不再等于“能力”|第0期 · 序章
安全·架构·agent