ContextBuilder实战:为大模型Agent构建可控的上下文管理
如果你一直在折腾大模型 Agent 应用ContextBuilder这个词大概率已经不陌生了。我最近在Hello-Agents这个教学项目里推进到 9.3 节整节都在讲 ContextBuilder 的落地实践。简单说它就是负责把系统提示词、用户问题、工具返回结果、历史记忆、知识库片段等一堆乱七八糟的信息按照一套可控的策略组装成大模型输入的上下文模块。刚接触 Agent 开发的人可能觉得这玩意不就是拼字符串吗实际踩一圈就会发现少了它你的 Agent 就是个情绪不稳定的临时工——上下文稍微一长模型就开始忘事、跑偏、甚至把工具返回的 JSON 原样念给你听。这篇文章就把我在 Hello-Agents 里实现 ContextBuilder 的完整过程拆开讲从设计思路到核心代码再到排坑记录希望帮你在自己的项目里少走几趟弯路。1. 为什么需要 ContextBuilder先把问题摊开很多人写 Agent 的第一步是把所有信息用 f-string 拼进一个 prompt然后丢给模型。Demo 阶段没问题一旦进入多轮对话、接了工具调用、挂了知识库检索这锅粥就开始糊了。ContextBuilder 要解决的正是这一层信息如何进入上下文、以什么顺序进入、哪些该留哪些该扔的工程问题。1.1 上下文就是 Agent 的工作台想象你是一个厨师面前有十个案板每个案板上堆着不同的食材系统指令是菜谱用户问题是客人的口头点单工具返回结果是刚买回来的半成品食材历史记忆是上一桌客人吃剩的菜。如果一个厨师把所有东西不分主次全部倒进锅里那做出来的菜基本不能吃。大模型也是一样——它是一口锅它的输入上下文就是工作台工作台的大小有上限台面上摆什么、摆在哪个位置直接决定了这锅菜的质量。大模型的上下文窗口确实是限制条件几千到几万 token 不等看起来很多但一次工具调用返回的 JSON 就可能吃掉上千 token多轮对话里的历史消息更是指数级膨胀。ContextBuilder 就是帮你管理这个工作台的工具它决定先放什么、后放什么、哪些东西放不下了就压缩重写、哪些东西干脆丢掉。它不是一个花哨的功能而是 Agent 能否在真实场景里稳定工作的地基。1.2 没有 ContextBuilder 时你会遇到的三件事我在 Hello-Agents 的前面几节里故意先做了几个不带 ContextBuilder 的版本好让自己对痛点有体感。踩下来的问题集中在三个地方第一是指令淹没。系统提示词辛辛苦苦写了五百字约束要求模型只输出结构化结果不要废话结果工具返回了一个巨大的表格模型回话时把约束全忘了直接开始列 Markdown 表格。原因很简单超长的中间内容把开头指令的注意力稀释了。模型对 prompt 开头和结尾的内容更敏感中间那些夹心层很容易被忽略这在学界有个说法叫lost in the middle。你的系统指令一旦被埋进中间层效果就废了。第二是Token 爆炸。多轮工具调用下来每一轮的原始返回都累积在上下文里。比如一个查天气的 Agent用户问了五天的天气每天的接口都返回一个 2KB 的 JSON五轮下来就是 10KB 的纯原始数据其中用户真正需要的信息可能只有十几个字。更要命的是模型每输出一个字都要把整个上下文重新算一遍上下文越长响应越慢、成本越高。很多人的 Agent 跑着跑着就报context length exceeded多半就是没做上下文管理。第三是信息冲突。Agent 在某一轮拿到了某仓库还剩 30 件库存这个事实存进了历史记忆下一轮工具又返回该仓库只剩余 10 件两条信息同时出现在上下文里模型就可能犯迷糊答出两种数据都有这种和稀泥的答案。ContextBuilder 要做的一件事就是明确信息层级和时效性让最新的工具结果在组装时天然比旧记忆更靠后、更靠近问题位置。1.3 为什么这事儿不能在 prompt 里硬写解决有人会说我在 prompt 里写一句请忽略过时信息优先采用最新数据不就行了吗我试过效果极其不稳定。大模型对指令的遵循是概率性的尤其是面对长上下文时中段指令的遵循率会进一步下降。更关键的是压缩和截断这种确定性操作不应该交给模型自己做——你让模型自己摘要它可能自由发挥出一段含糊的话你用规则去截断、抽关键字段、按优先级丢弃每一次行为都是可预测、可测试、可回归的。ContextBuilder 的本质是把上下文管理从一个 prompt 工程问题变成一个普通的工程问题。规则是代码写的压缩策略是代码定的输出是结构化的这样你的 Agent 才具备可调试性。这也是 Hello-Agents 项目里专门用一节来讲它的原因它是连接模型能力和业务确定性之间的那层胶水。2. Hello-Agents 里的 ContextBuilder 设计思路Hello-Agents 本身是一个教学型 Agent 示例项目理念是一个章节讲透一个轮子。9.3 这一节聚焦的就是 ContextBuilder之前的章节已经把 Agent 的骨架搭好了大体上分了 Builder、Planner、Executor、Reflector 四个模块9.3 就是要把其中的 Builder 从简单拼接升级到有序组装。2.1 先看整体架构Builder 在整个 Agent 里的位置在 Hello-Agents 的设计里一次 Agent 的完整响应流程大概是这样的接收用户消息 → Builder 组装当前轮上下文 → Planner 决定要调用哪些工具 → Executor 执行工具调用并拿到结果 → 结果返回给 Builder再组装下一轮上下文 → 直到判断不再需要工具输出最终回答。也就是说每一轮模型的输入都要经过 Builder 的手。Builder 是每一轮模型调用前的最后一道关卡也是工具结果回来后第一道处理工序。如果 Builder 做得粗糙后面 Planner 和 Executor 再聪明也没用——模型连现在处于什么任务阶段、手上有什么数据都搞不清楚怎么可能做好计划。在 Hello-Agents 的实践中Builder 的输入被抽象成了四类系统元信息SystemMeta包括角色设定、输出约束、工具定义、用户目标UserGoal当前这轮用户到底要什么、工具结果列表ToolResultList本轮或前几轮拿到的外部数据、记忆片段列表MemoryChunkList来自长期记忆模块的历史经验。输出则是一个结构化的 Prompt 包包含最终的消息列表、总 token 预估和组装日志。2.2 数据结构设计输入输出先定清楚接口设计直接决定后续扩展的便利性。我参考了 Hello-Agents 里的实现用 Python 做了这样一套数据类from dataclasses import dataclass, field from typing import Any, Optional dataclass class SystemMeta: role: str 你是智能助手 rules: list[str] field(default_factorylist) tools: list[dict] field(default_factorylist) dataclass class UserGoal: content: str meta: dict field(default_factorydict) dataclass class ToolResult: tool_name: str raw_data: Any None summarized: str created_at: float 0.0 dataclass class MemoryChunk: content: str created_at: float 0.0 importance: float 0.5 dataclass class BuildResult: messages: list[dict] field(default_factorylist) total_tokens: int 0 dropped_items: list[str] field(default_factorylist)这套结构看起来简单但每个字段都有讲究。ToolResult 里我同时保留了raw_data和summarized两个字段前者留给需要精确数据的场景后者是经过清洗的摘要文本ContextBuilder 默认用摘要只有在必要时才选用原始数据。MemoryChunk 里带一个importance字段这是给后续压缩策略用的——重要性低的记忆可以优先丢弃。2.3 组装顺序为什么近端优先真的管用组装顺序是 ContextBuilder 的核心决策。我在 Hello-Agents 里试过好几种排列方式最后稳定的模板是这么一套系统提示词SystemMeta含角色、全局规则、工具说明少量 few-shot 示例如果有长期记忆摘要只放精选过的历史经验不是全量历史本轮之前的工具结果摘要按时间从旧到新排列最近的工具结果最新鲜的数据放最靠后用户当前输入放在离模型注意力最近的位置为什么这么排两个原因。第一是模型对 prompt 开头和结尾的敏感度高于中间所以最重要、最需要被遵守的内容放开头系统指令最需要被直接响应的内容放结尾当前问题。第二是信息时效性——越靠近结尾的信息模型越倾向认为它是新近的、有效的这符合 Agent 任务对最新状态的需求。我做过一个对照组实验同样的任务把用户输入放在中间、把历史记忆放在最后结果模型频繁出现答非所问和错把旧信息当事实的问题把顺序修正为上述模板后同样的数据错误率降了一大截。所以别小看这个顺序它比你在 prompt 里写一百句请注意最新数据都管用。3. ContextBuilder 核心实现从零手写一份可用的代码概念说得再多不如直接看代码。我在 Hello-Agents 9.3 里实现的 ContextBuilder核心逻辑分成四步归一化输入 → 分配预算 → 组装内容 → 后处理校验。这一节带你完整走一遍。3.1 归一化把不同来源的信息转成统一消息格式模型 API 接收的通常是一串消息列表system / user / assistant / tool 四种角色但我们的输入是结构各异的数据。归一化这一步就是把 SystemMeta、UserGoal、ToolResult、MemoryChunk 全部转成标准消息块。def _to_messages(self, metas: list[dict], goal: str, summaries: list[str]) - list[dict]: messages [] meta_text \n.join(metas) messages.append({role: system, content: meta_text}) for i, s in enumerate(summaries): role assistant if i % 2 0 else user messages.append({role: role, content: f[中间信息块 {i}] {s}}) messages.append({role: user, content: goal}) return messages这里有个细节中间信息块我故意标记了编号并用虚拟的 assistant/user 交替角色而不是全部塞成 system 或 user。原因有二一是一堆连续同角色消息在部分 API 上会报错或触发合并二是交替角色可以让模型把这些块识别为过往的推理过程而不是新的用户指令有效防止中间内容被误判成高优先级指令。3.2 预算分配Token 不是全花在刀刃上而是按刀刃分配直接拼字符串的问题就是你永远不知道什么时候超限。我在 ContextBuilder 里加了一个简单的 token 估算器并在 build 之前先做预算规划。假设模型窗口是 4096 token这是很多入门模型的常见配置我的分配策略是内容块预算占比大约 token说明系统指令工具定义20%800最核心绝不压缩few-shot 示例10%400有就放没有就释放给其他块长期记忆摘要25%1000只放精选记忆工具结果摘要30%1200按时间新旧排队用户当前输入15%600保证原文完整def _allocate_budget(self, total_limit: int, meta_len: int, goal_len: int, tool_results: list[ToolResult], memories: list[MemoryChunk]): budget { meta: int(total_limit * 0.20), goal: int(total_limit * 0.15), memory: int(total_limit * 0.25), tool: int(total_limit * 0.30), } # 如果目标消息特别长从工具预算里借额度 if goal_len budget[goal]: overflow goal_len - budget[goal] budget[goal] overflow budget[tool] - overflow return budget这里的预算不是绝对死板而是提供了一种兜底机制任何一块内容超限系统会先尝试压缩压缩后仍不够就丢弃低优先级块从而保证系统指令和用户目标永远有空间。这套逻辑比直接截断先进的地方在于它不会因为某次工具返回特别大就把系统指令挤到窗口外。3.3 压缩策略摘要、滑动窗口、字段抽取三选一预算分配完之后真正动手砍内容的时候到了。我在 Hello-Agents 里实现了三种压缩手段按需组合摘要压缩对历史工具结果调用一次轻量模型或直接截取关键字段把 2KB 的 JSON 压缩成仓库当前库存30 件。核心思路是只保留任务需要的结构化字段丢掉中间过程数据。比如def _summarize_tool_result(self, result: ToolResult) - str: if result.summarized: return result.summarized # 从工具返回的原始 JSON 里抽关键字段 if isinstance(result.raw_data, dict): keys (count, stock, status, result, data) picked {k: v for k, v in result.raw_data.items() if k in keys} return json.dumps(picked, ensure_asciiFalse) return str(result.raw_data)[:200]滑动窗口对多轮历史消息只保留最近 N 轮更早的直接丢弃。这个 N 通常根据任务复杂度设定为 3~6 轮。滑动窗口对工具调用链比较长但每轮都相对独立的任务最有效。字段抽取针对固定结构的工具结果直接读取对象里我们关心的字段其余全部放弃。这是最省 token 的办法但要求工具返回格式稳定。三个策略的使用原则是能用字段抽取就用字段抽取抽不了就摘要摘要超预算才滑动窗口丢弃。顺序反了信息损耗会明显增加。3.4 动态更新每轮 build 之间的状态流转ContextBuilder 不只是单次组装还需要支持多轮状态更新。我在实现里维护了一个_current_state字典记录当前已用的工具结果摘要和记忆摘要每次 build 时把它作为上一轮状态叠加进去class ContextBuilder: def __init__(self, max_tokens: int 4096): self.max_tokens max_tokens self._state {tool_summaries: [], memory_summaries: []} self.debug_log [] def build(self, meta: SystemMeta, goal: UserGoal, tool_results: list[ToolResult] | None None, memories: list[MemoryChunk] | None None) - BuildResult: tool_results tool_results or [] memories memories or [] # 第一步归一化 # 第二步更新全局状态 self._state[tool_summaries].extend( self._summarize_tool_result(r) for r in tool_results ) self._state[memory_summaries].extend( m.content for m in memories if m.importance 0.3 ) # 第三步预算分配 # 第四步组装消息 messages self._to_messages( metas[meta.role] meta.rules, goalgoal.content, summariesself._state[tool_summaries][-3:] self._state[memory_summaries][-2:] ) self.debug_log.append({ round: len(self.debug_log) 1, messages_len: len(messages), rough_tokens: self._estimate_tokens(messages) }) return BuildResult(messagesmessages, total_tokens..., dropped_items...)动态更新的关键是哪些状态要跨轮保留。工具结果摘要我会限制只保留最近 3 条更早的压缩成一条总摘要记忆摘要只保留重要性高且比较新的 2 条。这样既保留了任务所需的连续性又避免了无限增长。3.5 后处理校验不让脏数据进模型Build 完之后还有一个容易被忽略的步骤校验。我在 Hello-Agents 里加了一个validate方法检查三件事消息角色是否合法、总 token 是否超限、是否存在明显的空内容块。如果超限则不直接抛错而是触发一次紧急压缩把工具摘要再进一步裁剪再不行就触发异常让上层感知。def build(self, meta, goal, tool_resultsNone, memoriesNone) - BuildResult: result self._do_build(meta, goal, tool_results, memories) if result.total_tokens self.max_tokens: # 紧急压缩优先砍旧记忆、再砍旧工具摘要 self._emergency_shrink(result) return result这一步的价值在于它把上下文超限从运行时可能随时炸掉的隐患变成了可控、可观测的异常分支。上层代码可以通过判断dropped_items了解模型到底没看到哪些内容从而决定要不要提醒用户数据较多仅展示部分结果。4. 实操记录在 Hello-Agents 里跑通一个多轮工具调用场景代码写完总要放到真实场景里检验。我在 Hello-Agents 9.3 配套做了一个校园信息查询Demo——用户连续提问Agent 反复调用工具获取数据ContextBuilder 全程管理上下文。整个过程记录下来很有参考价值。4.1 场景设定这个 Demo 的任务是模拟一个校园信息查询助手用户可以连续问某课程的教室在哪这门课的选课人数还够不够最近一次考试时间等问题。Agent 背后接了两个工具一个查课表信息一个查选课统计。这两个工具的原始返回都是 JSON字段多、value 长非常不适合完整塞进上下文。4.2 第一版不启用 ContextBuilder翻车实录我特意先做了一个对照组——不用 ContextBuilder直接把系统提示词、用户问题和工具返回的 JSON 全量拼接成一个 user 消息丢给模型。跑了两轮对话后问题就来了第一轮系统提示词要求只输出简洁中文这个规则在第二轮几乎失效。原因是系统提示词在最前面而长长的工具 JSON 堆在中间模型被中间的大块数据分散了注意力。第二轮回答里模型居然原样复述了一段 JSON 里的原始字段名还加了一句根据数据您看这样可以吗这类废话。第二轮上下文 token 数已经接近 3000而前两轮工具返回里真正有价值的信息教室在 A302可选人数 12 人只占实际 token 的 5%。这个 Demo 只跑了几轮就快撞上窗口上限如果继续第三轮、第四轮妥妥超限报错。4.3 第二版启用 ContextBuilder逐步恢复秩序接入 ContextBuilder 后我先定义了工具结果的清洗规则对课表工具只抽course_name、room、time_slot三个字段对选课工具只抽course_name、quota、selected三个字段。这样一条工具返回从 600 token 直接压到 60 token 左右的摘要。再看组装效果。第二轮用户问完ContextBuilder 构建出的 messages 大致是system角色输出约束工具定义assistant第一轮工具摘要 课程 XX 的教室为 A302user那这门课的选课人数还剩多少assistant第二轮工具摘要 课程 XX 可选人数 12 人模型在看到这个上下文后稳定地输出了简洁回答不再复述 JSON 字段。后面我又测了连续问五轮ContextBuilder 通过滑动窗口及时丢掉了最早一轮的工具摘要全程 token 控制在 1500 以内回答质量和第一轮基本持平。4.3 数据对比ContextBuilder 到底带来了什么跑完两版我整理了一下数据指标直接拼接版ContextBuilder 版单轮平均 token 消耗约 2400约 800五轮累计 token 消耗超出窗口第三轮即报错约 1200因滚动丢弃旧摘要回答格式合规率第二轮开始明显下降五轮稳定信息错误率偶尔引用过时数据未出现最有价值的提升其实是可观测性。ContextBuilder 把每一轮 build 的 messages 和 token 统计都记录在了debug_log里。当回答质量有问题时我第一时间打开日志看模型到底看到了什么、漏掉了什么而不是对着黑盒猜。这个能力在调试 Agent 时太重要了。4.4 社区反馈补充Hello-Agents 项目组后来补的两个细节这个 Demo 在项目里跑了一段时间后有同行反馈了两个改进点非常实用值得抄进自己的实现第一工具结果摘要里最好加一个数据时间戳字段。因为多个工具返回值可能来自不同时刻模型如果不清楚时效性容易把旧数据当新数据用。ContextBuilder 组装摘要时可以直接把时间信息拼进文本里比如截至 09:30可选人数剩余 12 人。第二对于极长的工具结果直接摘要可能会丢失关键上下文最好在摘要里保留原始数据 hash或原文截断地址方便排查时追溯。我在实现里加了一个可选字段raw_ref默认不写入 prompt但会记录在 debug 日志里。这样任何一次模型输出异常都能快速回到原始数据核对排查效率高了很多。5. 常见问题与排查技巧实录写 ContextBuilder 的过程中踩了不少坑这里整理出五个高频问题每个都是真实发生过、并且被反复问到的。5.1 工具返回的原始 JSON 太大预算根本不够很多人第一次接入工具后发现工具的返回体动辄几千 token比如一个搜索接口会返回几十条结果每条都带冗长的描述。这时候直接做摘要都来不及因为摘要也需要模型调用耗时和成本反而更高。我的建议是优先做字段级过滤在数据源层面就只接收必要字段。如果工具是你自己写的尽量提供fields参数让接口端就返回精简结构如果工具是第三方的在 ContextBuilder 里用 JSONPath 或字典键筛选先剔除明显无用的展示字段再做摘要。这样能从源头把 token 吃满的问题解决掉一大半。5.2 系统提示词在长上下文中失效这是最经典的问题。做法不是把系统提示词变长而是确保系统提示词在消息列表的第一位并且中间不要插入比它更长的同角色内容。如果你在 system 之后又塞了很长的 assistant 消息模型的注意力会被显著分散。如果系统提示词本身很长还可以在关键约束部分做末尾重复强化把最重要的三条规则在消息列表的最后、用户问题之前再以 system 或 assistant 身份重复一遍。我实测下来这种头部声明尾部重申的方式比单纯放头部更稳。ContextBuilder 里我加了一个reinforce_rules配置默认只对最核心的规则做重申避免冗余。5.3 模型突然失忆明明前面说过的话后面忘了排查思路不要一上来就怀疑模型能力先看上下文里到底有没有你要它记住的内容。很多时候是 ContextBuilder 的滑动窗口已经把旧信息丢掉了。这时候需要判断这个信息是本轮临时的还是需要长期保留的。长期信息应该让记忆模块写入长期存储并在后续轮次作为 MemoryChunk 重新注入临时信息则反正会被丢丢了就丢了不影响任务。我的建议是给 MemoryChunk 的importance字段设定一个合理的阈值importance 0.7的记忆可以跨多轮保留0.3~0.7 的只保留最近一两轮低于 0.3 的直接丢弃。这比记住所有内容的策略高效得多。5.4 模型总在中间层产生误解顺序调整了也没用如果你按照推荐顺序组装模型还是误解中层信息多半是中间信息块本身的语义问题。我在 5.1 里提到用交替角色包裹中间信息这里还有一个补充技巧在每条工具摘要前加一个指令前缀比如请参考以下工具返回的信息非用户指令帮助模型明确区分数据来源和指令来源。这属于提示词姿态控制可以有效降低模型把工具数据误当成用户要求的概率。5.5 调试上下文一定要加 debug 开关这是我个人强烈建议的一个习惯。ContextBuilder 必须支持把每一轮组装出来的 messages 完整 dump 下来存成 JSON 文件或打进日志。调试 Agent 时90% 的诡异问题都能通过检查模型实际看到的上下文定位到根因要么是某条工具摘要没有正确写入要么是记忆模块放进了过期的内容要么是预算分配把用户输入截断了。没有这个开关你只能对着模型的错误回答瞎猜那才是真的浪费时间。最后分享一个我在 Hello-Agents 里实践出来的小技巧ContextBuilder 不要设计成一次性生效的黑盒而是像状态机一样把每一轮的输入、输出、丢弃项都暴露出来。你在调试时打开dropped_items看看往往能发现很多意外——比如重要的工具结果被预算机制砍掉了或者某条历史记忆因为importance打分不合理而永久丢失。顺着这些日志调几轮你的上下文策略才会越用越顺。这个模块看似不起眼但它是整个 Agent 系统里最值得花时间打磨的地方之一。