Agent上下文构建实战:ContextBuilder设计与工程实践
做Agent项目做到第9章我越来越确定一件事模型能力决定Agent的下限上下文构建决定Agent的上限。Hello-Agents是我一直在维护的一套入门级Agent示例项目9.3节正好落在ContextBuilder这个组件上。这个组件看起来只是把用户输入、历史记录、工具返回结果拼在一起可真要把它做扎实需要考虑信息优先级、Token预算、来源标记、去重压缩等一系列工程问题。这篇文章我会把Hello-Agents里ContextBuilder的设计思路、落地代码和踩坑过程完整摊开适合正在搭Agent原型、被上下文管理折腾得头疼的开发者参考。1. 为什么我在Hello-Agents里单独做一节ContextBuilder1.1 多数Agent翻车不是模型不行是上下文喂得不对早期做Hello-Agents的Demo时我第一版Agent只有两个模块一个负责调模型一个负责调工具。逻辑也很直白用户问一句我把历史消息全部塞进Prompt再调用工具把工具返回结果拼在最后继续让模型回答。听起来没毛病但实际跑起来问题特别多。最常见的情况是用户连着问了几轮问题中间有一次工具调用返回了特别长的JSON下一次提问时模型就分不清哪些是当前用户需求、哪些是历史工具结果。有时候模型会一本正经地基于过期的工具结果编答案有时候又把系统提示词里的规则当成用户说的话。最离谱的一次它把工具返回里的错误码当成用户情绪回复了一段安慰话。后来我复盘才发现这不是模型不够聪明而是上下文没有结构。把所有信息线性拼接等于把一堆文件堆在桌面上让模型自己找重点它当然会乱。ContextBuilder要解决的就是这件事在把文本送进模型之前先完成一次有规则的组装。1.2 ContextBuilder要解决的三类问题Hello-Agents里我给ContextBuilder定义了三类必须解决的输入问题这也是所有Agent项目都绕不开的第一类是信息过载。对话历史、知识库检索片段、工具返回结果、用户当前输入来源太多。如果全塞进去单次请求很快就超Token上限。尤其是工具返回的大段JSON一下就能吃掉几千Token。第二类是信息顺序与优先级混乱。模型对上下文中间部分的注意力往往不如开头和结尾。用户刚刚说的需求应该放在靠后位置系统指令应该放在最前面工具结果则要根据是否仍有效来决定放在中间还是尾部。顺序一旦错了回答质量立刻下降。第三类是来源不可追溯。很多Agent项目里模型其实是猜着用工具结果的。上下文里没有清晰标记哪段内容来自哪个工具、生成时间是什么模型就无法判断这段信息的可信度。ContextBuilder需要在每个内容块上打标签让模型知道我看到的这段话是10秒前的天气接口返回不是用户的最终目标。这三类问题在Hello-Agents的9.3节里被拆成了三个子模块InfoCollector负责收拢数据ContextBuilder负责组装排序ContextPolicy负责压缩截断。下面重点讲ContextBuilder本身的实现。2. ContextBuilder的核心设计从“拼接字符串”到“结构化组装”2.1 一个最直白的ContextBuilder接口如果只用一个词概括ContextBuilder的定位我会选组装器。它的输入是一堆零散的上下文片段输出是一段结构清晰、附带了来源和优先级的文本。我先贴出Hello-Agents里最核心的接口定义。为了保证可读性示例代码做了简化但结构保持了项目里的原貌from dataclasses import dataclass from typing import List, Optional dataclass class ContextBlock: block_id: str role: str # system / user / tool / memory / retrieval content: str priority: int # 数值越大越重要 source: Optional[str] None # 比如 tool_name: weather_api timestamp: Optional[str] None class ContextBuilder: def __init__(self, max_tokens: int 4096): self.max_tokens max_tokens self.blocks: List[ContextBlock] [] def add_block(self, block: ContextBlock): self.blocks.append(block) def build(self) - str: # 按优先级和角色顺序排序 ordered self._sort_blocks(self.blocks) # 按Token预算截断 trimmed self._trim_to_budget(ordered) # 格式化成模型友好的文本 return self._render(trimmed)你可能会觉得这个接口太简单但实际项目中真正花心思的都在_sort_blocks、_trim_to_budget和_render这三个私有方法里。接口简单不代表实现简单把复杂逻辑放在内部外部调用方才能保持清爽。2.2 分块来源与优先级我最初设计ContextBlock时只有role和content两个字段后来才加了priority和source。这个变化源于一次真实翻车模型把旧工具结果当成实时数据使用了。加priority的意义是让ContextBuilder不再先来后到地排序而是按业务规则排序。Hello-Agents里我定义了一套默认优先级上下文片段类型role优先级说明系统指令system100始终保留在开头定义行为边界用户当前输入user90紧跟在系统指令后强调当前目标工具实时返回结果tool60有timestamp标记过期会被降级短期会话历史memory40只保留最近N轮压缩后放入知识库检索片段retrieval50按相关度分数决定内部排序这个优先级不是死的我做过不少调整。比如当用户的当前输入特别长时我会临时把它拆成意图摘要和详细内容两个块避免把模型注意力全占满。当工具结果明显过期时Policy模块会把它优先级压到20甚至直接丢进忽略列表。_source字段则是给模型看的引用来源。渲染出来之后模型看到的不是冷冰冰的JSON而是一段带标签的说明。例如[工具结果 weather_api, 获取时间 2025-01-15 10:23:00] { city: 上海, temp: 8°C }模型看到这个格式就会知道这是工具返回的客观数据而不是用户陈述或系统指令。这个小改动大幅减少了模型把工具输出当用户意图的问题。2.3 为什么用“区块角色元数据”而不是纯文本有位看了Hello-Agents源码的开发者问我为什么不直接在外部把字符串拼好而是非要做成板块对象我的回答是纯文本拼接只能解决有不能解决对和省。对指模型对上下文的理解正确。区块带role和source模型可以区分谁说的和哪来的。纯文本做不到这一点。你就算在字符串前加一个以下是工具返回模型仍然很难区分哪一行到哪一行属于工具结果边界尤其当工具返回内容本身就是一大段自然语言时。省指Token预算的精细控制。纯文本拼接的截断单位只能是字符但区块化之后可以按块裁剪。比如内存块超了直接把整个memory块替换成摘要而不是生硬地从中间切一刀。也可以针对tool块做字段级截断只保留JSON里的关键字段而不是把整个结构文本都塞进去。所以ContextBuilder内部虽然最终会渲染成一整段文本但它在管理阶段始终坚持结构化。这是Hello-Agents能保持上下文可控的根本原因。3. 在Hello-Agents中的落地实现细节3.1 项目里的模块划分与数据流Hello-Agents的9.3节并没有把ContextBuilder孤立地讲而是把它放在了一条完整的数据流里。整条链路是用户输入 - InfoCollector收集数据 - ContextBuilder组装 - ContextPolicy压缩 - Model调用 - Tool执行 - 返回结果回流到ContextBuilderInfoCollector负责从各个数据源拉取内容会话存储里的历史消息、向量库的检索结果、各类工具的返回数据。它会统一把这些数据包装成ContextBlock交给ContextBuilder。ContextBuilder拿到blocks之后不做任何外部IO只做排序、裁剪、渲染。这样设计的好处是方便测试我可以在单测里直接构造一堆blocks验证输出顺序是否符合预期。ContextPolicy则是一个可插拔的规则引擎。比如设置全局Token上限设置某种role的最大块数设置过期时间阈值。Policy会在ContextBuilder的build()方法里以回调方式参与处理而不是在外部单独跑一遍这样能保证任何对上下文的修改都会立刻体现在最终输出里。3.2 会话历史、工具结果、长期记忆怎么进Context三种最常见的上下文来源在Hello-Agents里的处理方式不太一样。会话历史走的是滑动窗口摘要策略。ContextBuilder内部维护一个最近K轮的列表超过窗口的早期消息不会直接丢弃而是先交给一个轻量摘要器生成一段早期对话摘要插在会话历史块的最前面。这么做的好处是模型能大致知道之前的来龙去脉又不会占用太多Token。我在实践里把K设为6轮再配合摘要效果比较稳定。工具结果走的是结构提取过期标记策略。原始工具返回往往是完整JSON但模型可能只需要其中的部分字段。InfoCollector会针对常用工具配置一个提取规则把关键字段抽出来并打上获取时间。ContextBuilder在排序时会优先保留最新时间戳的工具块。如果两个工具块都超过5分钟且用户没有明确要求刷新Policy会建议模型直接告知用户数据可能过时而不是伪造实时性。长期记忆走的是相关性筛选策略。向量检索经常会把相似但不相干的内容捞回来ContextBuilder会根据相关度阈值过滤掉低于0.7的块。在组装时retrieval块统一放在用户当前输入和工具结果之后避免知识库信息喧宾夺主。3.3 构建规则截断、去重、压缩组装过程中最容易被忽略的是去重。有一次我在调试中发现模型回答的内容明明来自一条历史记录但那条记录同时被memory块和retrieval块各带了一次。结果模型把同一个信息念了两遍还出现了一次矛盾说法。查下来发现是InfoCollector在收集时没有做全局内容指纹。Hello-Agents里的解决办法是对每个block计算一个基于内容的哈希指纹。块与块之间如果指纹相同保留优先级更高且来源更可信的那个另一个直接丢弃。同时如果记忆块里的某句话已经出现在工具结果里也会做一次交叉去重。截断规则我整理成了一张表方便对照上下文类型默认Token上限超限处理方式system块800不允许截断超过则预警tool块1200提取关键字段后截断memory块1500滑动窗口压缩为摘要retrieval块1000按相关度排序只保留Top3user当前输入1000拆分为意图摘要和详细内容压缩策略也有讲究。简单的做法是让模型对超长文本做摘要但这样会增加一次额外模型调用响应时间会变长。所以ContextBuilder里默认先做规则压缩删除空白、压缩JSON、截断长列表。只有当规则压缩后仍然超限才触发模型摘要。我实测下来90%的场景用规则压缩就能解决问题。4. 实测效果与调优经验4.1 一组对比实验有/无ContextBuilder为了验证ContextBuilder到底有没有用我在Hello-Agents里做了一组对照实验。实验场景是同一个工具查询助手让它连续处理10个用户问题其中穿插两个需要调用天气接口和一个需要调用计算器接口的任务。第一组关闭ContextBuilder直接把所有历史记录和工具返回按原始顺序拼接。第二组启用ContextBuilder使用默认的优先级排序、Token预算和去重逻辑。结果差异非常明显。无ContextBuilder的那组第一轮回答质量还行到第4轮之后就开始出现上下文漂移模型偶尔会忘掉用户之前明确说过的偏好。第8轮时模型甚至把一次工具返回里的异常堆栈当成了用户报错内容。有ContextBuilder的那组10轮里保持了比较稳定的响应格式关键信息没有丢失工具结果的引用也基本准确。Token使用量方面无ContextBuilder组平均每轮消耗约3200 Token有ContextBuilder组约2300 Token。原因在于去重和截断规则减少了很多重复信息。别小看这几百Token的差距在真实生产环境里每天几万次调用费用差距相当可观。4.2 遇到的三个坑及排查过程坑一优先级冲突导致system块被挤出开头位置。有段时间我发现模型总是会违反系统指令里的输出格式要求反复检查Prompt没发现问题。后来我打印了build()方法最终生成的上下文才发现当用户输入特别长时系统块的token被截断到只剩300而工具块因为优先级设置为80排序排到了system块前面。排查过程花了些时间。我先在ContextBuilder里加了debug视图可以输出每个block在排序后的实际位置和保留Token数。对比后发现是优先级定义出现了重叠我给某个工具块设置了priority90和用户当前输入同级。排序算法不稳定时同样优先级的内容会按插入顺序排列导致系统块被挤到第二位。修复方式是把系统块的优先级提到100并强制在_render()里把system块固定放在最前位置不参与排序。这之后问题再也没有出现。坑二历史轮数设置过大会遗忘关键信息。我原本把滑动窗口设为20轮以为保留越多信息越好。结果模型反而抓不住重点有时会参考第15轮的内容回答第19轮的问题产生非常奇怪的上下文混淆。排查之后我意识到窗口保留的只是最近20轮所有内容中间夹杂了大量寒暄和中间工具结果真正关键的用户目标反而被淹没了。后来我把contextBuilder和memory模块拆开调试逐个轮次标记用户目标句识别出每一轮里用户真正想做的事。ContextBuilder在组装memory块时只提取每轮的目标句其余内容全部丢弃。这样窗口即使扩展到20轮模型也能快速定位用户核心需求。坑三工具结果去重失败旧数据覆盖新数据。这个坑很隐蔽。当时我在工具块上加了timestamp字段排序规则里也写了按时间戳降序但测试时发现模型仍然引用了旧数据。后来查代码发现排序发生在去重之后去重逻辑在没有timestamp比较的情况下直接按照内容指纹保留了第一个出现的块。由于旧块先进入列表新块被当成重复内容丢掉了。修复方法是对去重逻辑加上时间戳比较当内容指纹相同但timestamp不同时保留新块并同时保留来源标记。这个案例给我的教训是去重不止要比内容也要比新鲜度。ContextBuilder的这些坑普通项目调参很难发现只有逐层打印上下文才能定位。4.3 调试ContextBuilder的实用技巧分享几个我在调试过程中觉得特别高效的技巧。一是在ContextBuilder里设计一个debug模式。开启后build()方法除了返回渲染好的文本还会输出一个blocks列表包含每个块的分组、优先级、token占用、是否被截断。这样你就能清清楚楚看到模型到底看了什么。二是使用最小复现测试排查上下文问题。当某次回答异常时把那次构建出的原始block列表保存成JSON然后写一个单测用同样的输入重新构建上下文。这样可以把问题从模型随机性中剥离出来专注检查构建逻辑。三是定期统计各类块的Token占比。我每隔一段时间跑一批真实对话日志统计system、tool、memory、retrieval各自占了多少Token。如果tool块长期占比超过50%说明工具返回的字段提取策略太粗糙如果memory块占比极小说明窗口设置太激进历史信息可能已经被压缩得失去价值。这种数据驱动的调优比凭感觉调参靠谱得多。5. 可复用的模板与扩展方向5.1 一个通用ContextBuilder模板如果你不是Hello-Agents的用户只是想在自己的Agent项目里引入一套类似的上下文管理可以直接参考下面这个模板。它不依赖特定框架只需要Python 3.9。import hashlib import json from typing import List class GenericContextBuilder: ROLE_PRIORITY { system: 100, user: 90, tool: 60, retrieval: 50, memory: 40, } def __init__(self, budgets: dict): self.budgets budgets self.blocks [] def add(self, role: str, content: str, source: str None, timestamp: str None, priority: int None): if priority is None: priority self.ROLE_PRIORITY.get(role, 50) self.blocks.append({ role: role, content: content, source: source, timestamp: timestamp, priority: priority, fingerprint: hashlib.md5(content.encode()).hexdigest(), }) def build(self) - str: # 去重 seen {} for block in self.blocks: fp block[fingerprint] if fp not in seen or block.get(timestamp, ) seen[fp].get(timestamp, ): seen[fp] block unique_blocks list(seen.values()) # 排序role基础优先级 业务优先级 unique_blocks.sort(keylambda x: (x[priority], x.get(timestamp, )), reverseTrue) # 压缩 rendered [] for block in unique_blocks: content self._truncate(block[role], block[content]) source f [{block[source]}] if block.get(source) else rendered.append(f[{block[role]}{source}]\n{content}) return \n\n.join(rendered) def _truncate(self, role: str, content: str) - str: budget self.budgets.get(role, 1000) if len(content) budget: return content # 简单截断也可替换为摘要逻辑 return content[:budget] ...[truncated]这个模板最大的价值是把组装逻辑和业务逻辑分离。你在自己的项目里只需要实现InfoCollector把不同数据源转成block剩下交给Builder处理即可。我应该提前说明一下这里的_truncate直接按字符截断效果比较粗糙。生产环境建议针对JSON、表格文本写专门的字段级截断器可以保留更多有效信息。这也是ContextBuilder持续演进的方向。5.2 从单轮到多Agent场景的演进ContextBuilder并不是只在单Agent项目里有用。我最近在Hello-Agents的9.4节规划里已经开始把ContextBuilder往多Agent协作方向扩展。多Agent场景的额外复杂度在于一个Agent接收到的上下文可能来自另一个Agent的输出。如果每个Agent都各自构建上下文很容易出现信息放大失真——一个Agent把自己的结论打包传给另一个另一个又把它当成原始信息层层传递后原始证据反而丢失。我的方案是在ContextBlock里增加一个provenance字段用来记录这条信息最初来自哪个模块、经过哪些Agent转发。下游Agent在组装上下文时可以看到完整的信息链路。如果链路过长Policy会把链路中关键节点的原始文本强制附带在最终摘要后面避免中间层失真。另外在多Agent场景下Token预算要更严格。因为每个Agent的输入都包含前序Agent的输出总消耗会成倍上涨。ContextBuilder需要新增一个全局预算协调器在多个Agent之间分配Token额度。某个Agent如果消耗过多协调器会让它先把上下文压缩后再输出给下游。这些功能还在迭代中但基础框架和单Agent版本完全一致。换句话说你只要把ContextBuilder的分块优先级来源追踪这套心智模型吃透无论Agent架构怎么变上下文管理都不会成为瓶颈。最后分享一个我个人的体会与其不断换更强的模型不如先把同一份上下文用更好的组织方式喂进去。ContextBuilder是我在Hello-Agents里投入产出比最高的一次实践它没有引入任何复杂算法只是把信息如何呈现在模型面前这个问题认真对待了。9.3这一节的内容值得每个做Agent应用的人在自己项目里复现一遍。