☰
Hello-Agents第9章上下文工程实战:从第二轮Context为空到多轮Agent稳定运行
2026/9/26 21:57:16 网站建设 项目流程

1. 从一次终端报错说起:为什么第二轮对话丢了 Context

第一次跑 Hello-Agents 第 9 章的时候,我在终端里盯着一段输出看了很久。第一轮模型回复得好好的,第二轮发过去的请求里,Context 字段居然是空的。不是报错,不是超时,就是安安静静地空了。那一瞬间我甚至怀疑是自己代码写漏了,反复翻了两遍messages的拼接逻辑,才意识到问题出在上下文工程的组装环节,而不是模型本身。

这件事让我重新理解了「上下文工程」这四个字的分量。很多人把它等同于提示词工程,觉得无非是把话说清楚一点。但真正在 Agent 场景里跑起来你会发现,提示词只是第一层,真正决定一个多轮 Agent 能不能稳定工作的,是每一轮往 LLM 里塞进去的那坨 Context 到底是怎么被构造、裁剪、传递和复用的。Hello-Agents 第 9 章讲的正是这套东西,而我在终端里踩的这个坑,恰好是这套机制最典型的一个失效点。

这篇内容适合两类人看:一类是刚开始接触 Agent 开发、准备跟着 Hello-Agents 动手跑一遍的;另一类是已经写过几轮对话逻辑,但总觉得模型「记性不好」「答非所问」,想搞清楚上下文到底该怎么管的。我会从整体设计思路讲到具体实现,再到我实际踩过的坑和排查方法,尽量把每一步为什么这么做都讲透,让你看完能直接对着自己的终端复现一遍。

2. 上下文工程到底在解决什么问题

2.1 提示词工程和上下文工程的边界在哪

先把概念理清楚,不然后面全是糊涂账。提示词工程关注的是「这一次我要怎么跟模型说」,它优化的是单次输入的表达质量,比如角色设定、任务描述、输出格式约束。上下文工程关注的是「这一轮我该让模型看到什么」,它管理的是跨轮次、跨工具、跨记忆的信息流。

打个比方,提示词工程像是你写一封邮件时的措辞,上下文工程像是你决定这封邮件里要附上哪些历史往来、哪些附件、哪些背景资料。邮件写得再好,如果该附的合同没附,对方照样没法办事。Agent 场景里,模型每一轮能看到的全部信息构成了它的「工作记忆」,而这个工作记忆是有限的、需要被主动管理的。

Hello-Agents 第 9 章把这块单独拎出来讲,我觉得是很有必要的。因为一旦你开始做多轮 Agent,上下文就不再是「把历史消息拼起来」这么简单了。历史会越来越长,工具返回的结果会越来越杂,记忆会越攒越多,而模型的上下文窗口是有硬上限的。你不主动管理,它就会以各种奇怪的方式失效——比如我遇到的 Context 为空。

2.2 一个 Agent 的上下文里通常装着什么

在 Hello-Agents 的框架里,一轮请求发给 LLM 的 Context 大致包含这几类信息:

  • 系统提示:定义 Agent 的身份、能力边界、行为规范,通常固定不变
  • 对话历史:用户和助手之前的往返消息,是上下文里增长最快的一块
  • 工具调用记录:Agent 调用过哪些工具、传了什么参数、拿到什么结果
  • 检索到的外部知识:从知识库或文档里捞出来的相关片段
  • 长期记忆:跨会话保留的用户偏好、事实性信息
  • 当前轮的用户输入:这一轮要处理的新消息

这六类信息不是简单堆在一起就完事。它们有优先级、有生命周期、有 token 预算。系统提示一般不动,对话历史要按策略裁剪,工具结果往往很长需要压缩,检索内容要控制条数,长期记忆要按相关性筛选。上下文工程的核心工作,就是在这几类信息之间做取舍和编排。

2.3 为什么第二轮特别容易出问题

第一轮请求通常是最简单的:系统提示加用户输入,结构清晰,没什么可丢的。到了第二轮,事情就复杂了。你需要把第一轮的用户输入、第一轮的模型回复、可能还有第一轮的工具调用结果,全部组装进新的 Context 里。任何一个环节的拼接逻辑写错,或者某个字段在传递过程中被覆盖、被清空,就会出现我遇到的那种「第二轮 Context 为空」。

更隐蔽的是,有些框架会把上下文管理拆成多个组件,比如一个负责历史裁剪,一个负责记忆注入,一个负责工具结果格式化。这些组件之间的数据流如果没对齐,就会出现「A 组件以为 B 组件会填 Context,B 组件以为 A 组件已经填了」的尴尬局面。终端里看到的只是一个空字段,背后可能是一整条数据链路的断裂。

3. Hello-Agents 第 9 章的上下文组装设计

3.1 整体架构:分层组装而不是一次性拼接

Hello-Agents 第 9 章的设计思路是分层组装。它没有把所有信息揉成一个大字符串,而是把上下文拆成几个独立的层,每层有自己的构造逻辑和更新时机,最后再按顺序合并成最终发给 LLM 的 messages 数组。

这种设计的好处很直接。第一,每层可以独立测试,你可以在终端里单独打印某一层的内容,看它是不是符合预期。第二,每层可以独立裁剪,比如对话历史太长时只压缩历史层,不影响系统提示和工具记录。第三,每层可以独立替换,比如你想换个记忆存储方案,只改记忆层就行,不用动其他部分。

我实测下来,这种分层结构在调试时特别省事。当 Context 为空时,我可以逐层打印,很快定位到是哪一层没产出内容,而不是对着一坨拼接好的字符串猜。

3.2 各层的职责与数据流

按照第 9 章的思路,上下文组装大致分这么几层:

层级职责更新时机典型 token 占比
系统层身份、规范、能力说明初始化时固定5% - 10%
记忆层长期偏好、事实每轮按相关性检索5% - 15%
知识层检索到的文档片段每轮按查询检索10% - 30%
历史层对话往返记录每轮追加并裁剪30% - 50%
工具层工具调用与结果工具执行后追加10% - 25%
当前层本轮用户输入每轮设置5% - 10%

这个占比不是硬性规定,而是一个经验参考。实际项目里要根据任务类型调整,比如客服类 Agent 历史层占比会更高,知识问答类 Agent 知识层占比会更高。

数据流是这样的:每轮开始时,当前层先被设置;然后记忆层和知识层根据当前输入去检索;历史层从上一轮的状态里继承并追加;工具层在工具执行后动态插入;最后所有层按固定顺序合并,交给 LLM。

3.3 为什么顺序不能随便调

层的合并顺序是有讲究的。系统层必须放最前面,因为它是模型的「行为准则」,放后面容易被淹没。当前层通常放最后,因为模型对末尾内容的注意力更强,把本轮问题放最后能提升响应质量。历史层放中间,工具层紧跟在触发它的历史消息后面,保持因果连贯。

我试过把知识层放到历史层后面,结果模型经常忽略检索到的内容,因为它被大段对话历史「埋」住了。后来调回历史层之前,检索内容的利用率明显上升。这种细节在文档里不一定写,但实际跑起来差别很大。

4. 核心实现:从零组装一轮 Context

4.1 环境准备与依赖确认

动手之前先把环境理清楚。Hello-Agents 第 9 章的示例代码依赖 Python 3.10 以上,主要用到几个库:负责调用 LLM 的客户端库、负责 token 计数的工具库、以及框架自带的上下文管理模块。

python --version pip list | grep -i agent

确认版本没问题后,把示例代码拉下来。我建议单独建一个虚拟环境,避免和系统里的其他包冲突。

python -m venv venv source venv/bin/activate pip install -r requirements.txt

提示:如果你在 Windows 终端里跑,激活命令是venv\Scripts\activate,别直接复制 Linux 的命令,否则会报找不到文件。

4.2 定义上下文层的基类

第 9 章的示例里,每一层都继承自一个基类,基类定义了统一的接口:build()负责产出这一层的内容,token_count()负责估算 token 数,trim()负责在超预算时裁剪。

class ContextLayer: def __init__(self, name, priority): self.name = name self.priority = priority self.content = [] def build(self, state): raise NotImplementedError def token_count(self): return sum(len(str(item)) for item in self.content) // 4 def trim(self, budget): while self.token_count() > budget and self.content: self.content.pop(0)

这里 token 估算用的是「字符数除以 4」的粗略方法,中文场景下这个比例不太准,实际项目里建议换成正经的 tokenizer。但在调试阶段,粗略估算够用了,重点是先把数据流跑通。

4.3 系统层的构造

系统层最简单,初始化时设定好就不再变。

class SystemLayer(ContextLayer): def __init__(self, system_prompt): super().__init__("system", priority=0) self.system_prompt = system_prompt def build(self, state): self.content = [{"role": "system", "content": self.system_prompt}] return self.content

系统提示的写法有讲究。我一般会包含三部分:Agent 的身份定位、它能做什么不能做什么、输出格式要求。不要写太长,控制在几百字以内,太长了反而稀释重点。

4.4 历史层的追加与裁剪

历史层是上下文里最动态的一块。每轮结束后,把本轮的用户输入和模型回复追加进去;每轮开始前,检查是否超出预算,超了就裁剪。

class HistoryLayer(ContextLayer): def __init__(self, max_tokens=2000): super().__init__("history", priority=3) self.max_tokens = max_tokens def append(self, role, content): self.content.append({"role": role, "content": content}) def build(self, state): self.trim(self.max_tokens) return self.content

裁剪策略我试过几种。最简单的从头删,但会把最早的对话丢掉,有时候最早的对话恰恰包含关键背景。后来改成「保留第一条和最近 N 条」,中间的直接丢弃,效果更稳。再复杂一点可以用摘要,把被裁掉的历史压缩成一段摘要保留,这个成本高一些但信息损失小。

4.5 工具层的动态插入

工具层比较特殊,它不是每轮都有,而是当 Agent 决定调用工具时才产生。工具调用的结果往往很长,需要压缩后再放进上下文。

class ToolLayer(ContextLayer): def __init__(self, max_result_tokens=500): super().__init__("tool", priority=4) self.max_result_tokens = max_result_tokens def add_call(self, tool_name, args, result): trimmed = self._trim_result(result) self.content.append({ "role": "tool", "name": tool_name, "content": trimmed }) def _trim_result(self, result): text = str(result) if len(text) // 4 > self.max_result_tokens: return text[:self.max_result_tokens * 4] + "...[truncated]" return text

工具结果截断是个双刃剑。截太狠,模型拿不到关键信息;截太少,上下文被撑爆。我的经验是,结构化结果(比如 JSON)优先保留字段名和关键值,长文本结果优先保留开头和结尾,中间截断。

4.6 合并与最终请求构造

所有层准备好后,按优先级顺序合并。

def assemble_context(layers, state): messages = [] for layer in sorted(layers, key=lambda l: l.priority): messages.extend(layer.build(state)) return messages

合并完还要做一次全局 token 检查。如果总量还是超了,就按优先级从低到高继续裁剪,直到符合预算。这一步是最后的安全网,防止某一层单独看没超,合起来超了。

5. 那个「第二轮 Context 为空」的坑,我是怎么定位的

5.1 现象复现与初步排查

回到开头那个问题。第一轮正常,第二轮 Context 为空。我先在组装函数里加了一行打印,把每一层的内容都输出到终端。

for layer in layers: print(f"[{layer.name}] {len(layer.content)} items")

结果发现历史层是 0 items,其他层正常。也就是说,第一轮的对话根本没有被追加到历史层里。问题不在组装,而在追加环节。

5.2 根因:状态对象被重新初始化了

继续往上查,发现每轮开始时会创建一个新的 state 对象,而历史层是挂在 state 上的。新 state 一创建,历史层就是空的,上一轮的内容自然丢了。

# 错误写法 def handle_turn(user_input): state = AgentState() # 每轮都新建,历史丢失 state.history.append("user", user_input) ...

正确做法是把 state 提到循环外面,或者用一个持久化的会话对象来管理。

# 正确写法 state = AgentState() while True: user_input = input("> ") state.history.append("user", user_input) ...

这个坑很典型。它不报错,不崩溃,就是安静地丢数据。如果你没在终端里逐层打印,很难发现。

5.3 排查这类问题的通用思路

我把这类问题的排查思路整理成一张表,方便对照。

现象可能原因排查方法
某层内容为空状态未持久化打印每层 item 数
历史越来越短裁剪策略过激检查 trim 阈值
模型忽略检索内容层顺序不对调整知识层位置
token 超限报错全局检查缺失加合并后总检查
工具结果丢失截断逻辑有 bug打印截断前后长度

注意:排查上下文问题时,永远先在终端里逐层打印,不要靠猜。上下文是数据流问题,肉眼可见的打印比任何推理都可靠。

5.4 一个容易被忽略的细节:消息角色顺序

还有一个坑值得单独说。有些模型对 messages 数组里的角色顺序有隐式要求,比如 system 必须在最前,user 和 assistant 必须交替。如果你把两条连续的 user 消息拼在一起,某些模型会直接报 400 错误,提示 context 格式不对。

我遇到过一次,工具层插入的位置不对,导致出现了连续两条 tool 消息,模型直接拒绝。后来在合并后加了一个校验函数,检查角色序列是否合法,问题就再没出现过。

def validate_messages(messages): for i in range(1, len(messages)): if messages[i]["role"] == messages[i-1]["role"]: if messages[i]["role"] not in ("tool",): raise ValueError(f"连续相同角色: {messages[i]['role']}")

6. 上下文预算管理:token 到底该怎么分

6.1 先搞清楚你的模型窗口有多大

不同模型的上下文窗口差别很大,从几千 token 到上百万 token 都有。动手前先确认你用的模型窗口上限,然后留出 20% 左右的余量给输出,剩下的才是输入预算。

比如窗口是 128k,输出预留 4k,那输入预算大概 124k。但这不意味着你要把 124k 塞满,实际用下来,塞得越满,模型对中间内容的注意力越差,响应质量反而下降。我的经验是把输入控制在窗口的 50% 到 70% 之间,效果最稳。

6.2 各层的预算分配策略

预算分配没有标准答案,但有个原则:越靠后、越和当前问题相关的层,预算越充足。

  • 系统层:固定,通常几百 token
  • 记忆层:按相关性取 top 3 到 top 5,控制在 1k 以内
  • 知识层:按相关性取 top 3 到 top 5 片段,控制在 2k 到 4k
  • 历史层:占大头,可以给到总预算的 40% 到 50%
  • 工具层:按需,单个结果控制在 500 token 以内
  • 当前层:用户输入本身,通常不大

这个分配在客服、问答、任务执行三类场景里都试过,基本够用。特殊场景再微调。

6.3 动态调整而不是一刀切

固定预算在简单场景够用,但复杂场景下会出问题。比如某一轮用户问了一个需要大量检索的问题,知识层需要更多预算,这时候就该从历史层借一点。

我实现过一个简单的动态分配:先给每层一个基础预算,然后根据当前输入的类型调整。如果检测到是知识型问题,知识层预算翻倍,历史层减半;如果是闲聊型,历史层保持,知识层压缩。

def allocate_budget(query_type, total_budget): if query_type == "knowledge": return {"history": total_budget * 0.3, "knowledge": total_budget * 0.5} elif query_type == "chat": return {"history": total_budget * 0.6, "knowledge": total_budget * 0.1} else: return {"history": total_budget * 0.45, "knowledge": total_budget * 0.3}

这个逻辑不复杂,但效果提升明显。模型在知识型问题上的回答准确率上去了,闲聊时的连贯性也没丢。

7. 实操心得与常见问题速查

7.1 我踩过的几个典型坑

第一个坑就是前面说的状态未持久化。这个坑的教训是:上下文相关的对象,生命周期要和会话对齐,不要和单轮对齐。

第二个坑是工具结果没截断。有一次调用了一个返回大 JSON 的工具,结果直接把上下文撑爆,模型报 400。后来加了截断,但截断位置没选好,把关键字段截掉了,模型拿到的是一堆无意义的开头。最后改成保留字段名加前几个值,问题才解决。

第三个坑是记忆层检索太宽泛。早期我用向量检索取 top 10,结果塞进去一堆不相关的内容,模型反而被干扰。后来收紧到 top 3,并且加了一个相关性阈值,低于阈值的直接不要,效果好了很多。

7.2 常见问题速查表

问题原因解决
第二轮 Context 为空状态对象每轮重建会话级持久化 state
模型报 400 超长未做全局 token 检查合并后统一裁剪
模型忽略检索内容知识层位置靠后调整到历史层之前
工具结果丢失截断逻辑截错位置保留字段名和关键值
历史裁剪丢关键信息从头删策略保留首条加最近 N 条
角色顺序报错连续相同角色合并后校验角色序列

7.3 几个提升稳定性的小技巧

第一,给每一层加日志。不是打印全部内容,而是打印层名、item 数、token 估算值。这样出问题时一眼能看出是哪层异常。

第二,写一个上下文快照功能。每轮组装完,把最终的 messages 存一份到本地文件,出问题时可以回放。这个在调试复杂多轮场景时特别有用。

第三,给裁剪逻辑写单元测试。构造一个超长的历史,跑一遍裁剪,断言结果符合预期。裁剪逻辑是最容易出 bug 的地方,有测试兜底心里踏实。

第四,token 估算别用字符数除以 4。中文一个字大概 1 到 2 个 token,英文一个词大概 1 到 1.5 个 token。用正经的 tokenizer 算,误差小很多。调试阶段可以粗略,上线前一定要换准的。

8. 上下文工程后续还能怎么扩展

跑通第 9 章的基础版本后,我顺着往下做了几个扩展,效果都不错,分享给你参考。

第一个扩展是上下文压缩。当历史层超预算时,不是简单丢弃,而是调用一次 LLM 把旧历史压缩成摘要。这样信息损失小,但多了一次模型调用,成本和延迟都上去了。适合对连贯性要求高的场景。

第二个扩展是分层记忆。把长期记忆再拆成「事实记忆」和「偏好记忆」,事实记忆用向量检索,偏好记忆直接全量注入。偏好通常不多,全量注入反而更稳。

第三个扩展是上下文缓存。系统层和记忆层在很多轮里是不变的,可以把这部分缓存起来,只重新计算变化的部分。这个在长会话里能省不少 token 和时间。

第四个扩展是上下文可视化。写一个简单的终端界面,把每一层的内容用不同颜色打印出来,直观看到上下文是怎么构成的。调试时特别爽,一眼就能看出哪层占了大头。

这些扩展不是必须的,但如果你打算把 Agent 用到生产环境,迟早会碰到需要它们的时候。先把基础版本跑稳,再按需加,别一上来就全上,那样调试成本太高。

我个人在实际操作中的体会是,上下文工程最难的从来不是写代码,而是想清楚「这一轮模型到底需要看到什么」。代码只是把这个思考落地,思考不清楚,代码写得再漂亮也是白搭。每次出问题,先回到这个问题上问自己一遍,往往比盯着代码看半天更有效。

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

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

立即咨询