当 Agent 从单次问答走向连续交互,历史消息会迅速成为新的工程负担:全部保留会挤占上下文并推高成本,直接删除又可能破坏工具调用结构或丢失关键事实。要让对话既连贯又可控,需要把 token 估算、按轮裁剪和摘要压缩组合成完整的历史管理机制。
摘要:承接前两篇的手写 ReAct Agent 与 Function Calling 重构,本文给它加上多轮对话能力——历史怎么累积、token 怎么数、超预算了按什么边界裁剪、被裁掉的轮怎么用摘要救回来(滚雪球合并)。这是 Context Engineering 的核心一课:什么信息、什么时机进入上下文。
上篇结尾留了个钩子:Function Calling 版 Agent 工具齐了、错误回传稳了,但它是一条"金鱼"——run(question) 里创建 messages,答完即弃。你问它"上海今天天气怎么样",它答得很好;紧接着问"那北京呢?",它一脸茫然:"那"指什么,它不知道。
单问变多轮,表面上是小改动。但历史一旦真正累积起来,会撞两堵墙:
所以多轮 Agent 的核心问题不是"怎么把历史带上"(一个 list 的事),而是"怎么管理历史":什么信息留下、什么压缩、什么丢弃、在什么时机做这些决定。这就是行业共识里取代 Prompt Engineering 的那个词——Context Engineering:能决定"什么信息在什么时机进入上下文窗口"的人,比会写漂亮提示词的人值钱。
把"管理历史"拆开:
对应的三个实现件:token 计数器、按轮裁剪器、摘要压缩器。
先说好消息:单问变多轮,Agent 核心循环一行不用动,改动只有三处:
# week5: def run(question): —— messages 在函数里创建,用完即弃
# week6: 历史由调用方持有,函数接收并返回它
def ch@t_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() 放在调模型之前:预算约束的是即将发出的请求体积,等响应回来再裁,钱已经花了;效果立竿见影——指代测试:
你> 上海今天天气怎么样?
助手> 上海今天天气:多云,28°C。……
你> 那北京呢?
===== 第 1/8 步 =====
▶ 调用 get_weather({"date": "今天", "location": "北京"})
----- 工具返回 -----
北京 今天 晴 25°C
助手> 北京今天天气:晴,25°C。比上海凉快一些,……
"那北京呢"五个字里,"那"指天气、"北京"是新地点——全靠历史接住。注意最后一句"比上海凉快一些":模型不仅接住了指代,还主动做了跨轮对比。这是单问版不可能出现的行为。
精确计数比想象中难:tokenizer 依赖具体模型,GPT-4o 和 DeepSeek 词表不同,同一句话各家数出来不一样。生产做法二选一:tiktoken 库(OpenAI 系精确,跨厂商也只是更准的近似),或事后读 API 响应的 usage 字段(精确但滞后,只能指导下一轮)。
教学版用经验近似,零依赖:
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 同样计费。漏算它们会系统性低估成本:
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
超预算了,从哪里切?先定义"轮":
一轮 = 一条 user 消息 + 其后到下一条 user 消息之前的所有 assistant / tool 消息
为什么边界必须是轮的起点?回忆上篇的协议:assistant(tool_calls) 与 role="tool" 消息靠 tool_call_id 配对,这是 API 的硬性要求。从中间劈开历史、拆散任何一半配对,API 直接 400。而轮的起点(user 消息)天然是配对安全的切分位置——轮内所有 tool 消息的前面必有它们的 assistant。
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 才是单位 |
外加一条铁律:最新一轮无条件保留。宁可这一轮超预算多花点钱,也不能把用户刚说的话裁掉——那是事故级错误。实现上就是"从最新往老装轮时,第一轮无条件收下"。
丢弃策略有个隐患:用户第 1 轮说"记住我的代号是 42",聊到第 10 轮触发裁剪,第 1 轮出局——Agent 失忆了。
解法是把出局的轮压缩成一条摘要。摘要指令本身就是 Context Engineering——保什么、扔什么必须显式写出来:
只保留四类信息:
① 用户的整体目标与意图
② 关键事实(称呼、数字、约束、偏好)
③ 已做出的决定及结果
④ 尚未解决的问题
丢弃寒暄、重复和工具原始输出的细节。
两个容易漏掉的设计:
滚雪球合并。如果每次只摘要"这次新出局的轮",那么第 2 次溢出时,第 1 次生成的旧摘要会在替换时被丢掉——记忆每溢出一次就失忆一次。正确做法是把旧摘要一并送进摘要器合并:
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("nn".join(parts))
工具输出先截断再送摘要。工具返回值是上下文里"体积最大、时效最短"的成分——渲染进摘要文本时只保留前 300 字。八成的体积贡献不到一成的记忆价值,这笔账很好算。
摘要消息以 role="system" 存放(排在系统提示词之后):它和人格一样属于"常驻背景知识",不参与轮的切分与裁剪。识别旧摘要靠内容前缀约定((更早对话的摘要)),不引入协议外字段。
这是 Context Engineering 的心智模型,也是整篇的收束:
| 信息 | 进上下文的方式 | 时机 | 理由 |
|---|---|---|---|
| 系统提示词 | 常驻,永不裁剪 | 会话开始 | 每次决策都依赖;丢了人格漂移 |
| 工具 Schema | 常驻,每次随发 | 每次调用 | 模型"知道有哪些工具"全靠它 |
| 用户当前问题 | 常驻 | 刚发生 | 被裁掉 = 事故 |
| 最近几轮原文 | 常驻(预算内) | 自然累积 | 指代消解主要靠近几轮原文 |
| 更早的对话 | 压缩成摘要后进入 | 溢出发生时 | 原文全留太贵 |
| 历史摘要 | 常驻,替换式更新 | 每次溢出滚雪球 | 防止反复失忆 |
| 工具返回值 | 短暂停留 | 当轮保留,出局时最先牺牲 | 体积最大、时效最短 |
被裁危险度排序(裁剪器实现顺序的依据):本轮用户问题(事故)> 早前关键事实(失忆)> 工具原始输出(基本无感)。
把预算压到 150 token(演示用;正常对话给 4000+),完整触发"裁剪 → 摘要 → 滚雪球 → 记忆验证":
你> 记住我的代号是 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。
两次溢出,各一次摘要,注意第二次发生了什么:
/history 体检最终状态:
共 4 条消息,估算 143 token:
0 system 45 字 你是一个借助工具解决问题的助手。……
1 system 140 字 (更早对话的摘要)- 用户目标:查询天气…… - 关键事实:用户……
2 user 8 字 我的代号是多少?
3 assistant 13 字 你的代号是 42。
四条消息装下了整场对话的"要点 + 当前轮"——这就是历史管理的全部意义。
按痛的程度排序:
/history 的实际估算。"先测量再动手"这条原则,自己演示时也会忘。assistant(tool_calls) 和 tool 消息极易被劈开。轮的起点是唯一安全的切分边界。三种策略的取舍,一张表收束:
| 策略 | 成本 | 信息损失 | 适用 |
|---|---|---|---|
| 丢弃(窗口/预算) | 零 | 出局轮彻底消失 | 闲聊、低事实密度对话 |
| 摘要压缩 | 每次溢出一次 API 调用 | 细节丢、要点留 | 长任务(名字/目标/决定必须记住) |
| 原样保留 | 每 token 每轮重复计费 | 无 | 只配给最近几轮——模型对原文的记忆远好于摘要 |
面试题"多轮对话的上下文怎么管理?超长怎么办?",现在的回答可以这么展开:先测量(token 计数,近似就够,决策只需要量级);超预算按完整轮裁剪(轮 = user 消息到下一条 user 之前,tool_call_id 配对不能拆散);出局的轮用摘要挽救,四要素显式声明保什么,旧摘要滚雪球合并防反复失忆;最新一轮和系统提示词永远保留;工具原始输出是最先牺牲的对象。管住了这些,Agent 才从 Demo 走向可用。
下一步:RAG。记忆管住了,但模型还是不知道你公司的知识——文档解析、分块策略、向量检索、引用溯源,这是"答得对且可追溯"的那条链路。
ubuntu怎么选择最快的更新源? ubuntu更改最快的更新源的图文教程
Claude 订阅停止覆盖第三方 Agent 后还能通过 OAuth 使用吗?
Ubuntu系统的笔记本触摸板怎么调节鼠标光标速度?
Claude Code 订阅套餐和 API 按量调用的成本如何管理?
新一代 AI 工具进阶指南:从基本使用到高效工作流
Pi Agent 应该如何通过 Agent SDK 正确连接 Claude 或 Codex?