☰
Hello-Agents 第9章:多轮对话上下文工程与消息累积实战
2026/9/26 19:16:10 网站建设 项目流程

1. 从一次终端里的翻车说起:为什么第二轮对话丢了 Context

第一次跑 Hello-Agents 第 9 章的时候,我在终端里盯着日志看了足足十分钟,脑子里只有一个念头:这不对。第一轮请求明明把系统提示、用户输入、工具描述全都塞进去了,模型也老老实实回了一个像模像样的答案;可到了第二轮,发出去的 payload 里messages数组干干净净,只剩下一句新的用户输入,前面那堆上下文像是被谁顺手删了。模型当然一脸懵,回答得驴唇不对马嘴,甚至开始自己编工具名。

这个问题不是 Hello-Agents 独有的,任何自己手写 Agent 循环、自己拼 messages 的人都可能踩。它的本质是:上下文工程(Context Engineering)不是把 prompt 写漂亮,而是把"每一轮该带什么、不该带什么、带多少"这件事当成一个工程问题来管理。第 9 章之所以单独拿一章讲这个,就是因为前面几章大家写的 Agent 都是"一轮游",一旦进入多轮工具调用,上下文管理立刻变成核心矛盾。

这篇文章适合三类人看:一是正在跟着 Hello-Agents 或者类似教程写 Agent、卡在多轮对话上的;二是自己用 LLM API 搭过小工具、发现模型"记性差"的;三是想搞清楚"提示词工程"和"上下文工程"到底差在哪的。我会把第 9 章的核心思路拆开,补上教程里没细说的参数计算、消息裁剪策略、工具结果回填方式,再把我自己踩过的坑整理成一张排查表。全程以终端实操为主,不依赖任何图形界面。

先说结论,免得你看到一半才发现方向不对:第二轮丢 Context,九成以上的原因是消息历史没有被显式地累积和传递,而不是模型或框架的 bug。Agent 循环里每一轮都是一次全新的 API 调用,LLM 本身是无状态的,它不会"记得"上一轮说过什么。你以为是对话,其实是一串独立的请求,中间靠你自己维护的messages列表串起来。这个认知一旦建立,后面所有问题都好办了。

2. 上下文工程到底在工程什么:先搞清楚 LLM 的无状态本质

2.1 提示词工程和上下文工程的分界线

很多人把这两个词混着用,其实它们解决的是不同层次的问题。提示词工程关心的是"这一句话怎么写",比如角色设定、few-shot 示例、输出格式约束;上下文工程关心的是"这一轮请求里到底放哪些内容、放多少、按什么顺序放"。前者是文案活,后者是数据结构和资源调度的活。

打个比方,提示词工程像是你给助理写的一张便签,告诉他"用正式语气回复客户";上下文工程像是你决定这次把哪些邮件、哪些附件、哪些历史记录一起塞进文件夹递给他。便签写得再好,如果文件夹里是空的,助理照样干不了活。第 9 章标题里那个"第二轮没有 Context",就是文件夹空了。

从工程角度看,上下文工程要处理四类内容:

  • 系统级内容:system prompt、角色定义、全局约束,通常每轮都要带,且位置固定在最前面。
  • 对话历史:user 和 assistant 的往返消息,决定模型"记得"多少。
  • 工具相关:工具定义(tools schema)、工具调用请求(tool_calls)、工具执行结果(tool role 消息)。
  • 外部检索内容:RAG 召回的文档片段、知识库命中结果,按需注入。

这四类内容的生命周期完全不同。系统级内容基本不变,对话历史线性增长,工具结果是一次性的,检索内容是条件性的。把它们混在一个列表里无脑 append,迟早会撞上上下文长度上限,或者让模型被无关信息干扰。

2.2 为什么 LLM 不会自己记住上一轮

这是新手最容易误解的一点。LLM 的推理过程是纯函数式的:给定输入 token 序列,输出下一个 token 的概率分布。它没有内部状态,没有记忆,两次 API 调用之间没有任何隐式连接。你看到的"多轮对话"效果,完全是客户端每次把完整历史重新发一遍造成的幻觉。

我用一个具体的终端实验说明。假设第一轮我发:

{ "messages": [ {"role": "system", "content": "你是一个只会用中文回答的助手。"}, {"role": "user", "content": "我叫小林,记住这个名字。"} ] }

模型回复"好的,小林,我记住了"。然后第二轮我如果只发:

{ "messages": [ {"role": "user", "content": "我叫什么?"} ] }

模型必然答不上来,因为它根本没收到"我叫小林"这条信息。正确做法是把第一轮的 system、user、assistant 三条消息全部带上,再追加新的 user 消息。这就是上下文累积,也是第 9 章要解决的核心问题。

注意:有些框架封装了"会话"概念,看起来像是自动记忆,但底层依然是每次把历史拼进请求。一旦你绕过框架直接调 API,或者框架的会话对象没被正确传递,历史就断了。排查时永远先看实际发出的 payload,不要相信封装层的表象。

2.3 上下文窗口是一块有限的地皮

上下文窗口(context window)是模型单次请求能接受的最大 token 数,包括输入和输出。不同模型差异很大,从几万到上百万 token 都有。热词里那个maximum context length is 1048576 tokens的报错,说的就是输入加输出超过了这个上限。

这里有个容易算错的点:上限是输入加输出之和,不是只有输入。如果你给输入留了 100 万 token,输出就没空间了,请求会直接失败。实操中一般给输出预留 10% 到 25% 的余量。假设模型上限 128k token,你计划输出最多 4k,那输入预算就是 124k 左右,再留点安全边际,按 120k 来规划比较稳。

token 和字符的换算没有固定比例,中文大约 1 个 token 对应 1 到 2 个汉字,英文大约 1 个 token 对应 4 个字符。但代码、JSON、特殊符号的 token 密度完全不同,一段紧凑的 JSON 可能比同样长度的自然语言多出 30% 以上的 token。所以不要用字符数估算 token,要用模型对应的 tokenizer 实际数。Python 里可以用tiktoken之类的库,或者直接调模型提供方的计数接口。

3. 拆解 Hello-Agents 第 9 章的核心设计思路

3.1 Agent 循环的本质是一个 while 循环

Hello-Agents 这类教程里,Agent 的主循环结构大同小异,用伪代码表示大概是:

messages = [{"role": "system", "content": system_prompt}] while not done: response = call_llm(messages, tools=tool_schemas) messages.append(response.message) if response.has_tool_calls: for call in response.tool_calls: result = execute_tool(call) messages.append({"role": "tool", "content": result, "tool_call_id": call.id}) else: done = True

关键就在messages这个列表。它是在循环外初始化的,循环内不断 append,每一轮调用 LLM 时传的都是当前完整的messages。第 9 章要讲的"上下文工程",本质上就是围绕这个列表做三件事:怎么累积、怎么裁剪、怎么组织。

我第一次跑出错,就是因为把messages的初始化写在了循环内部,每轮都重置成只有 system 加最新 user 输入。结果就是标题里说的"第二轮没有 Context"。这个 bug 极其常见,因为代码看起来完全合理,只有打印出实际 payload 才能发现。

3.2 消息角色的语义不能乱用

OpenAI 风格的消息格式里,role 有 system、user、assistant、tool 四种(不同厂商可能略有差异)。每种角色的语义和位置约束都不一样:

角色作用位置约束常见错误
system全局设定与约束通常在最前放在中间导致部分模型忽略
user用户输入任意把工具结果塞进 user
assistant模型回复,含 tool_calls任意手动伪造 assistant 消息格式不对
tool工具执行结果必须紧跟对应 tool_call缺少 tool_call_id 导致报错

工具结果必须用 tool 角色,并且带上对应的tool_call_id,这是很多模型强制校验的。我见过有人图省事把工具结果拼成一条 user 消息发回去,模型有时也能用,但一旦涉及多个工具调用就会错乱,因为模型分不清哪条结果对应哪个调用。热词里那个provider rejected the request schema or tool payload的报错,十有八九就是 tool 消息的 schema 不对。

3.3 工具定义本身也占上下文

很多人只盯着对话历史,忘了tools参数里的工具 schema 也是要占 token 的。一个描述详细的工具定义,包含名称、描述、参数 JSON Schema,轻松几百 token。如果你挂了二十个工具,光工具定义就可能吃掉上万 token,而且每一轮都要重发。

这就引出一个优化点:工具不是越多越好,按当前任务动态筛选工具。比如用户问的是天气,你没必要把数据库查询、文件操作、邮件发送的工具全挂上。动态工具选择既省 token,又能降低模型选错工具的概率。Hello-Agents 第 9 章如果提到工具管理,大概率也是这个思路。

4. 实操:在终端里把上下文累积跑通

4.1 最小可复现的目录结构

我在终端里习惯用最简单的结构,方便排查:

hello-agents-ch9/ ├── agent.py # 主循环 ├── tools.py # 工具定义与执行 ├── context.py # 上下文管理逻辑 └── .env # 密钥配置,注意不要提交

.env里放 API key,用python-dotenv加载。这里插一句安全提醒:密钥永远不要硬编码进代码,也不要用 print 打印完整 payload 时把 Authorization 头带出来。热词里"使用 LLM 时如何防止密钥泄露"是个真问题,我自己的做法是日志里只打印 messages 和 tools,绝不打印请求头。

4.2 上下文管理器的实现

把上下文逻辑单独抽成一个类,比散落在主循环里好维护得多:

class ContextManager: def __init__(self, system_prompt, max_tokens=120000): self.system_prompt = system_prompt self.max_tokens = max_tokens self.history = [] def add_user(self, content): self.history.append({"role": "user", "content": content}) def add_assistant(self, message): self.history.append(message) def add_tool_result(self, tool_call_id, content): self.history.append({ "role": "tool", "tool_call_id": tool_call_id, "content": content }) def build(self): messages = [{"role": "system", "content": self.system_prompt}] messages.extend(self._trim(self.history)) return messages def _trim(self, history): # 先按 token 预算裁剪,策略见下一节 return history

主循环里只跟这个管理器打交道,永远不直接操作裸列表。这样"第二轮丢 Context"这类 bug 从结构上就被杜绝了,因为build()每次都会把 system 和历史一起返回。

4.3 打印实际 payload 验证

光看代码不够,一定要把真正发出去的内容打出来。我在call_llm之前加一行:

import json print(json.dumps({"messages": cm.build(), "tools": tool_schemas}, ensure_ascii=False, indent=2)[:2000])

截断到 2000 字符是为了终端不被刷屏,但足够看清 messages 数组里有没有历史。第一次跑通的时候,我看到第二轮 payload 里 system、第一轮 user、第一轮 assistant、第二轮 user 四条消息整整齐齐,那一刻才确认上下文真的接上了。

提示:调试阶段建议把完整 payload 写到文件而不是终端,用json.dump落盘,再用编辑器搜索关键词。终端滚动缓冲区有限,长 payload 很容易把关键信息冲掉。

5. 上下文裁剪:窗口快满时该丢什么

5.1 三种主流裁剪策略的取舍

上下文不可能无限增长,迟早要裁剪。常见策略有三种,各有适用场景:

策略做法优点缺点适用场景
滑动窗口只保留最近 N 轮实现简单丢失早期关键信息闲聊、短任务
摘要压缩把旧历史总结成一段保留语义有信息损失、多一次调用长对话
选择性保留按重要性打分保留精准实现复杂复杂 Agent

我自己的默认选择是滑动窗口加摘要:最近 5 轮原样保留,更早的内容用一次 LLM 调用压缩成一段摘要,作为一条 system 或 user 消息插在历史前面。这样既控制了 token,又不至于把早期约定全丢掉。

5.2 裁剪时不能碰的几类消息

裁剪不是无脑砍头。有几类消息一旦被裁掉,请求直接报错或者行为异常:

  • 带 tool_calls 的 assistant 消息和它对应的 tool 结果必须成对保留。只留一半,模型会看到孤立的 tool_call_id,直接 schema 校验失败。
  • system 消息永远保留,它是全局约束,丢了模型可能完全不按格式输出。
  • 当前轮的工具结果不能裁,模型正等着用它。

我踩过一次坑:滑动窗口按条数砍,正好把一条 assistant 的 tool_calls 砍掉、留下了 tool 结果,请求立刻返回 400。后来改成按"对话轮次"为单位裁剪,一轮里的 assistant 加所有 tool 结果作为一个原子单元,要么全留要么全丢,问题就没了。

5.3 token 预算的动态分配

固定条数裁剪不够精细,更好的做法是按 token 预算动态分配。思路是:

  1. 先算 system prompt 和 tools schema 占用的 token,从总预算里扣掉。
  2. 给输出预留固定额度,比如 4k。
  3. 剩下的额度从最新消息往前累加,直到接近预算上限。
  4. 如果最早的消息被截断,触发摘要压缩。

这个逻辑用代码表达大概是这样:

def fit_budget(messages, budget, count_tokens): kept = [] used = 0 for msg in reversed(messages): t = count_tokens(msg) if used + t > budget: break kept.insert(0, msg) used += t return kept

注意reversed保证优先保留最新消息,insert(0, ...)维持原始顺序。顺序对模型理解很关键,别用 append 把历史倒过来。

6. 工具调用场景下的上下文特殊处理

6.1 工具结果的体积控制

工具返回的内容经常很大,比如一次数据库查询返回几百行、一次网页抓取返回整页 HTML。这些内容直接塞进上下文,几轮就把窗口撑爆。我的做法是在工具执行层就做截断和摘要,而不是等上下文管理器来处理。

具体来说,工具函数返回前先判断长度,超过阈值就截断并加一句"结果已截断,共 N 条,显示前 M 条"。如果工具结果本身是结构化的,优先返回精简字段而不是原始 JSON。这一步在工具层做,比在上下文层做更合理,因为工具自己最清楚哪些字段重要。

6.2 多工具并行调用的消息顺序

一轮里模型可能同时发起多个工具调用,返回的 assistant 消息里tool_calls是个数组。执行完所有工具后,对应的 tool 结果消息必须按 tool_call_id 一一对应追加,顺序最好和 tool_calls 一致。有些模型对顺序不敏感,有些会严格校验,统一按顺序来最省心。

for call in response.tool_calls: result = execute_tool(call.function.name, call.function.arguments) cm.add_tool_result(call.id, result)

千万别把多个工具结果合并成一条消息,那样 tool_call_id 对不上,模型无法把结果和调用关联起来。

6.3 工具报错也要回填

工具执行失败时,很多人直接抛异常中断循环。更好的做法是把错误信息作为 tool 结果回填给模型,让它自己决定重试还是换方案。错误信息要包含工具名、参数、错误类型,但不要包含堆栈里的敏感路径或密钥。

try: result = execute_tool(name, args) except Exception as e: result = f"工具 {name} 执行失败:{type(e).__name__}: {e}" cm.add_tool_result(call.id, result)

这样模型看到失败原因,往往能自己调整参数重试,Agent 的鲁棒性会好很多。

7. 常见问题与排查速查表

7.1 报错信息对照表

把我在终端里实际遇到过的报错整理成表,方便你对号入座:

报错关键词可能原因排查方向
maximum context length exceeded输入加输出超窗口算 token,裁剪历史,检查工具结果体积
provider rejected schema or tool payloadtool 消息格式错检查 tool_call_id 是否配对、role 是否正确
第二轮回答丢失前文信息历史未累积打印 payload 看 messages 是否完整
模型反复调用同一工具工具结果没回填或格式错确认 tool 结果已 append 且 id 匹配
输出被截断输出预算不足给输出留足 token,或让模型分段输出

7.2 我的独家排查顺序

遇到上下文相关问题,我固定按这个顺序查,基本五分钟内定位:

  1. 打印实际 payload,确认发出去的是什么。九成问题在这一步就暴露了。
  2. 数 token,确认没超窗口,且输出有预留。
  3. 检查消息配对,tool_calls 和 tool 结果是否成对。
  4. 检查 role 语义,有没有把工具结果塞进 user。
  5. 检查循环结构,messages 是不是在循环外初始化。

这个顺序的核心逻辑是:从"实际发生了什么"往"代码怎么写的"倒推,而不是一上来就盯着代码猜。终端调试最大的优势就是能看到真实数据,别浪费这个优势。

7.3 几个反直觉的坑

  • 模型说"我记得"不代表它真记得。它可能只是在顺着你的话编,验证记忆要靠提问具体细节。
  • 上下文越长不一定越好。无关历史会稀释关键信息,模型注意力被分散,反而答得更差。该裁就裁。
  • 摘要压缩本身也会引入错误。摘要模型可能漏掉关键约束,重要约定最好在 system prompt 里再强调一遍。
  • 不同模型对消息顺序的敏感度不同。换模型后如果行为突变,先检查消息组织方式。

8. 把上下文工程做成可复用的能力

跑通第 9 章之后,我最大的体会是:上下文管理不该是每个 Agent 项目重写一遍的胶水代码,而应该沉淀成一套可复用的模式。我现在的做法是把 ContextManager 抽成独立模块,不同项目直接引入,只改 system prompt 和裁剪策略参数。

再往上一层,可以考虑给上下文加"标签"和"优先级"。比如给每条消息打上pinned(永不裁剪)、ephemeral(用完即弃)、normal三档,裁剪时按优先级处理。这样工具结果可以标 ephemeral,用户的核心需求标 pinned,系统约束天然 pinned。这套机制一旦建立,后面接 RAG、接多 Agent 协作都会轻松很多,因为你知道每一类内容该怎么管。

终端调试这件事本身也值得坚持。图形界面封装得越厚,你离真实 payload 越远,出问题时越难定位。我到现在跑任何新 Agent,第一件事都是把 payload 打到文件里看一眼。这个习惯帮我省下的时间,远比它多花的那几行代码值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询