1. 从零理解 ContextBuilder 到底在解决什么问题第一次看到 ContextBuilder 这个名字很多人会下意识觉得它就是个拼字符串的工具类没什么技术含量。我一开始也这么想直到在一个多轮对话项目里被上下文管理折磨了整整两周才真正意识到这个组件在整个智能体框架里扮演的角色有多关键。Hello-Agents 是一个面向智能体开发的框架它的核心思路是把一个智能体拆成若干可插拔的模块每个模块负责一件事。ContextBuilder 负责的就是其中最基础也最容易被低估的一环把散落在各处的信息按照一定的规则组装成模型能直接消费的上下文。听起来简单但实际做起来你会发现这里面的坑比想象中多得多。举个具体的场景。一个智能体在回答用户问题之前可能需要知道这些东西系统预设的人设指令、最近几轮的历史对话、从知识库里检索到的相关文档片段、当前可用的工具列表、以及用户本轮的实际输入。这些东西来源不同、格式不同、长度不同有的还需要做截断有的需要做优先级排序。如果全部硬编码在一个函数里代码会迅速变成一团乱麻。ContextBuilder 的价值就在于它把这套组装逻辑抽象成了可配置、可扩展的构建流程。我在实际项目里踩过的最大的一个坑就是早期没有把上下文构建独立出来导致每次调整提示词结构都要改动核心对话逻辑改一处崩三处。后来把这块逻辑抽成独立的 Builder 之后维护成本直接降了一个数量级。所以这篇文章我想把 ContextBuilder 的设计思路、核心实现、实操细节和常见问题完整地梳理一遍不管你是刚接触 Hello-Agents 的新手还是已经用过但没深究的老手应该都能从中找到一些有用的东西。这篇文章适合三类人看第一类是正在用 Hello-Agents 搭智能体、但对上下文管理还是一知半解的同学第二类是打算自己实现一套类似框架、想参考成熟设计的开发者第三类是遇到过上下文超长、信息丢失、优先级混乱等问题的实践者。我会尽量把原理讲透同时给出可以直接抄的代码和配置。2. ContextBuilder 的整体设计与思路拆解2.1 为什么要把上下文构建单独抽出来在讲具体实现之前我想先聊聊设计层面的取舍。很多人会问上下文构建不就是把几个字符串拼起来吗为什么值得单独做一个模块答案在于变化频率。在一个智能体系统里核心的对话循环逻辑其实相对稳定无非就是接收输入、调用模型、处理输出。但上下文的结构却经常变今天产品说要在系统提示里加一段人设明天算法说要把检索结果放在历史对话前面后天运营说工具描述太长了要精简。如果这些变化都直接改在对话循环里那核心逻辑就会被频繁的、低价值的改动污染。把 ContextBuilder 独立出来本质上是做了一个关注点分离对话循环只关心我要一个上下文至于这个上下文怎么拼、拼哪些部分、每部分多长全部交给 Builder 处理。这样两边的改动互不影响测试也好写——你可以单独给 Builder 写单元测试验证不同配置下产出的上下文是否符合预期而不需要跑整个对话流程。另一个原因是复用。同一个智能体框架里可能有多种不同的调用场景普通对话、工具调用、检索增强、多智能体协作。这些场景需要的上下文结构各不相同但底层的信息来源是共享的。有了 Builder 之后你可以为每种场景配置不同的构建策略共享同一套信息源避免重复代码。2.2 核心组成上下文里到底该放什么要设计一个 Builder第一步是搞清楚上下文里到底有哪些组成部分。根据我在多个项目里的经验一个典型的智能体上下文通常包含以下几类信息我按优先级从高到低排列优先级组成部分说明是否可截断1系统指令人设、行为约束、输出格式要求否2当前用户输入本轮的实际问题否3工具定义可用工具的名称、描述、参数可精简4近期对话历史最近若干轮的问答可截断5检索增强内容从知识库召回的相关片段可截断6长期记忆跨会话的摘要或事实可截断这个优先级排序不是随便定的它背后有一个很实际的考量越靠前的内容越不能被牺牲。系统指令决定了智能体的基本行为丢了它整个智能体就失忆了当前输入是必须回答的问题丢了它就没法干活工具定义影响能力边界可以精简但不能全丢而历史对话和检索内容属于锦上添花在长度受限时可以优先压缩。我在项目里见过一个典型的错误有人把检索内容放在了系统指令前面结果模型经常被检索到的无关片段带偏忽略了人设约束。这就是没有理解优先级导致的。ContextBuilder 的一个核心职责就是强制保证这个优先级顺序让调用方不需要每次都手动操心。2.3 构建流程的抽象从信息源到最终上下文理解了组成部分之后接下来要设计构建流程。我的做法是把整个流程拆成三个阶段收集、转换、组装。收集阶段负责从各个信息源拿到原始数据。这些信息源可能是对话历史管理器、向量检索器、工具注册表、记忆存储等。每个信息源提供的数据格式可能不一样有的返回消息列表有的返回文档对象有的返回结构化配置。转换阶段负责把原始数据统一成模型能消费的格式。比如把检索到的文档片段包装成带标注的文本块把工具定义序列化成特定的描述格式把历史对话裁剪到合适的长度。这一步是 ContextBuilder 里逻辑最复杂的部分也是最容易出问题的地方。组装阶段负责按照优先级顺序把转换后的内容拼接起来同时做最终的 token 预算检查。如果总长度超了就按照预设的策略从低优先级部分开始裁剪。提示把流程拆成三个阶段的好处是每个阶段都可以独立测试和替换。比如你想换一个检索器只需要改收集阶段的适配逻辑转换和组装完全不用动。2.4 与其他模块的边界划分设计模块时边界清晰比功能强大更重要。ContextBuilder 应该做什么、不应该做什么需要提前想清楚。它应该做的按配置收集信息、统一格式、控制长度、保证顺序。它不应该做的调用模型、执行业务逻辑、管理对话状态、做检索本身。这些分别属于模型客户端、业务层、会话管理器和检索器的职责。我见过有人把检索逻辑塞进 ContextBuilder理由是反正都要在这里拼进去。结果就是 Builder 越来越臃肿依赖越来越多最后变成了一个什么都干的上帝类。正确的做法是让 Builder 只接收已经检索好的结果检索本身由外部完成。这样 Builder 的依赖就非常干净测试起来也简单。3. 核心细节解析与实操要点3.1 消息格式的统一别小看这个环节上下文最终要喂给模型而模型对输入格式是有要求的。不同模型提供商的接口格式略有差异但主流的基本都是角色 内容的消息列表结构。ContextBuilder 转换阶段的核心任务就是把各种异构数据统一成这个结构。这里有个容易被忽略的细节角色标签的选择。系统指令通常用 system 角色用户输入用 user模型回复用 assistant。但检索内容和工具定义该用什么角色不同团队的做法不一样。有的把检索内容作为 system 消息的一部分有的单独作为一条 user 消息还有的用特殊的标记包裹后放在 system 里。我的经验是检索内容最好作为独立的 system 消息并且用明确的分隔标记包起来。比如def format_retrieval_context(docs): if not docs: return None parts [以下是与当前问题相关的参考资料] for i, doc in enumerate(docs, 1): parts.append(f[资料{i}] {doc.content}) parts.append(请基于以上资料回答如果资料中没有相关信息请如实说明。) return \n.join(parts)这样做的好处是模型能清楚地区分这是我的行为准则和这是给我的参考资料减少混淆。我实测下来加了明确分隔标记之后模型引用检索内容的准确率有明显提升。3.2 Token 预算的分配算清楚再动手上下文长度是硬约束超了要么报错要么被截断。所以 ContextBuilder 必须做 token 预算管理。这里的关键是提前分配而不是等到拼完了再检查。我的做法是给每个部分预设一个预算上限然后在转换阶段就按这个上限做裁剪。比如总预算 8000 token可以这样分配部分预算占比对应 token 数系统指令15%1200工具定义20%1600检索内容25%2000历史对话30%2400当前输入10%800这个比例不是固定的要根据实际场景调整。如果工具特别多工具定义的占比就要提高如果检索是核心能力检索内容的预算就要加大。关于 token 计算有个实操细节不要用字符数除以固定系数来估算。中英文混合的情况下这个估算误差很大。我建议直接用对应模型的 tokenizer 来精确计算虽然慢一点但准确。如果性能有要求可以做一个带缓存的 token 计数器相同文本只算一次。class TokenCounter: def __init__(self, tokenizer): self.tokenizer tokenizer self._cache {} def count(self, text): if text in self._cache: return self._cache[text] n len(self.tokenizer.encode(text)) self._cache[text] n return n注意缓存要注意内存占用如果文本量特别大建议用 LRU 策略限制缓存大小避免内存无限增长。3.3 历史对话的截断策略保留什么丢弃什么历史对话的截断是最考验设计的地方。简单粗暴的做法是从最旧的开始丢直到长度符合要求。但这样会丢失早期的重要信息比如用户一开始设定的偏好。我在项目里用过几种策略各有适用场景策略一滑动窗口。只保留最近 N 轮对话。实现简单适合对话主题频繁切换的场景。缺点是早期信息全丢。策略二保留首尾。保留最早的若干轮和最近的若干轮中间丢弃。适合用户一开始会交代背景、后面持续追问的场景。策略三摘要压缩。把早期对话用模型压缩成一段摘要保留摘要加最近对话。效果最好但需要额外调用模型有成本和延迟。策略四重要性打分。给每轮对话打一个重要性分数优先保留高分轮次。实现复杂但最灵活。实际项目里我通常用策略二作为默认因为它不需要额外模型调用效果也够用。具体实现时首部保留的轮数可以少一点比如 2 轮尾部保留的多一点比如 6 轮因为最近的内容通常更相关。def truncate_history(history, max_tokens, counter, head_keep2, tail_keep6): if counter.count_history(history) max_tokens: return history if len(history) head_keep tail_keep: return history[-tail_keep:] if len(history) tail_keep else history head history[:head_keep] tail history[-tail_keep:] return head tail3.4 工具定义的序列化描述要精炼但不能丢信息工具定义是上下文里很占空间的一块。每个工具都有名称、描述、参数列表参数还有类型和说明。工具一多光这部分就能吃掉几千 token。我的经验是工具描述要做到精炼但不丢关键信息。具体来说名称必须准确描述要说清楚什么时候用这个工具参数说明要包含类型和是否必填。至于详细的用法示例如果不是特别必要可以省略让模型自己根据描述推断。另外一个小技巧如果工具数量很多可以按类别分组只把当前场景相关的工具放进上下文。比如一个智能体同时有查询类、计算类、写入类工具如果当前是纯问答场景就可以只放查询类工具。这个筛选逻辑可以放在收集阶段由调用方指定需要哪些工具。3.5 配置化设计让 Builder 足够灵活ContextBuilder 要适应不同场景就必须支持配置。我的做法是定义一个配置对象描述每个部分的启用状态、预算、顺序等。class ContextConfig: def __init__(self): self.include_system True self.include_tools True self.include_retrieval True self.include_history True self.system_budget 1200 self.tools_budget 1600 self.retrieval_budget 2000 self.history_budget 2400 self.history_head_keep 2 self.history_tail_keep 6有了这个配置不同场景只需要传入不同的配置对象即可。比如纯对话场景把 include_retrieval 设为 False工具调用场景把 tools_budget 调大。这样一套 Builder 代码就能覆盖多种需求不用为每个场景写一份。4. 实操过程与核心环节实现4.1 环境准备与依赖梳理动手之前先把环境理清楚。Hello-Agents 本身是一个 Python 框架所以基础环境就是 Python 3.9 以上。核心依赖包括 tokenizer 库用于精确计算 token、以及框架自带的消息和工具抽象类。我建议在项目里单独建一个 context 模块把 ContextBuilder 相关的代码都放进去目录结构大概是这样context/ __init__.py builder.py # 核心构建逻辑 config.py # 配置对象 formatters.py # 各类内容的格式化函数 truncators.py # 截断策略 counter.py # token 计数这样分层的好处是每个文件职责单一改哪块找哪块不会在一个大文件里翻来翻去。4.2 核心构建流程的代码实现下面是我在实际项目里用的构建主流程做了适当简化但核心逻辑是完整的。class ContextBuilder: def __init__(self, config, counter, formatters): self.config config self.counter counter self.formatters formatters def build(self, system_prompt, user_input, toolsNone, retrieval_docsNone, historyNone): sections [] # 1. 系统指令最高优先级不截断 if self.config.include_system and system_prompt: sections.append({ role: system, content: system_prompt, priority: 1, truncatable: False, }) # 2. 工具定义 if self.config.include_tools and tools: tool_text self.formatters.format_tools(tools) tool_text self._fit_budget( tool_text, self.config.tools_budget, tools ) if tool_text: sections.append({ role: system, content: tool_text, priority: 3, truncatable: True, }) # 3. 检索内容 if self.config.include_retrieval and retrieval_docs: retrieval_text self.formatters.format_retrieval(retrieval_docs) retrieval_text self._fit_budget( retrieval_text, self.config.retrieval_budget, retrieval ) if retrieval_text: sections.append({ role: system, content: retrieval_text, priority: 5, truncatable: True, }) # 4. 历史对话 if self.config.include_history and history: history self._truncate_history(history) sections.extend(history) # 5. 当前输入最高优先级不截断 sections.append({ role: user, content: user_input, priority: 2, truncatable: False, }) return self._assemble(sections)这段代码里有两个关键点值得展开说。第一是_fit_budget它负责把某段文本裁剪到预算内。第二是_assemble它负责最终的排序和拼接。4.3 预算裁剪的具体实现_fit_budget的实现要考虑两种情况如果文本本身就在预算内直接返回如果超了就要按策略裁剪。对于工具定义和检索内容裁剪方式不太一样。工具定义的裁剪我倾向于按工具粒度裁剪而不是粗暴地截断文本。因为截断到一半的工具描述会让模型困惑。具体做法是逐个添加工具直到超出预算为止。def _fit_tools(self, tools, budget): result [] used 0 for tool in tools: text self.formatters.format_single_tool(tool) cost self.counter.count(text) if used cost budget: break result.append(text) used cost return \n.join(result) if result else None检索内容的裁剪则更适合按文档粒度而且可以按相关性排序后取前几个。如果单个文档就超预算那就对文档内容做截断并在末尾加省略标记。def _fit_retrieval(self, docs, budget): docs sorted(docs, keylambda d: d.score, reverseTrue) result [] used 0 for doc in docs: cost self.counter.count(doc.content) if used cost budget: remaining budget - used if remaining 100: truncated self._truncate_text(doc.content, remaining) result.append(truncated) break result.append(doc.content) used cost return result提示remaining 100这个阈值是我踩坑后加的。如果剩余预算太少截出来的内容只有几个字反而会干扰模型不如直接不加。4.4 组装阶段的排序与校验组装阶段要做两件事按优先级排序以及做最终的 token 校验。排序的逻辑是系统指令永远在最前当前输入永远在最后中间的部分按优先级排。这里有个细节历史对话本身是有顺序的不能打乱所以排序时要保持历史对话内部的相对顺序。def _assemble(self, sections): system_sections [s for s in sections if s[role] system] other_sections [s for s in sections if s[role] ! system] system_sections.sort(keylambda s: s[priority]) messages [] for s in system_sections: messages.append({role: system, content: s[content]}) for s in other_sections: messages.append({role: s[role], content: s[content]}) return self._final_check(messages)最终的 token 校验是最后一道防线。虽然前面每个部分都做了预算控制但加起来仍可能超总预算因为预算分配本身可能有重叠。所以最后要算一次总量如果超了就从优先级最低的可截断部分开始砍。def _final_check(self, messages, total_budget8000): total sum(self.counter.count(m[content]) for m in messages) if total total_budget: return messages # 从后往前找可截断的 system 消息 for i in range(len(messages) - 1, -1, -1): if messages[i][role] system and i 0: overflow total - total_budget new_content self._truncate_text( messages[i][content], self.counter.count(messages[i][content]) - overflow ) messages[i][content] new_content break return messages4.5 一次完整的构建过程记录为了让你更直观地理解整个流程我记录一次实际的构建过程。假设场景是一个带检索的问答智能体总预算 8000 token。输入信息系统指令约 300 token工具定义 5 个共约 1500 token检索到 4 个文档共约 3000 token历史对话 10 轮共约 4000 token当前输入约 100 token。第一步系统指令直接放入占用 300。第二步工具定义 1500 在预算 1600 内全部保留。第三步检索内容 3000 超过预算 2000按相关性排序后保留前 3 个文档约 1800 token。第四步历史对话 4000 超过预算 2400保留首 2 轮和尾 6 轮约 2200 token。第五步当前输入 100 放入。总计约 300 1500 1800 2200 100 5900 token在 8000 预算内构建成功。如果总预算只有 5000那最终校验阶段就会从检索内容开始进一步裁剪。这个过程中每一步的裁剪都有明确的依据不是拍脑袋决定的。这就是 ContextBuilder 相比硬编码拼接的价值所在。5. 常见问题与排查技巧实录5.1 上下文超长但不知道哪里超了这是最常见的问题。模型报错说超出长度限制但你不知道是哪部分导致的。我的排查方法是给 Builder 加一个调试模式在构建完成后打印每个部分的 token 占用。def build_with_debug(self, *args, **kwargs): messages self.build(*args, **kwargs) print( Context Debug ) total 0 for i, m in enumerate(messages): n self.counter.count(m[content]) total n preview m[content][:50].replace(\n, ) print(f[{i}] {m[role]:10s} {n:5d} tokens | {preview}...) print(fTotal: {total} tokens) return messages有了这个输出一眼就能看出是哪部分吃掉了预算。我遇到过好几次是工具定义意外膨胀因为某个工具的描述写得太啰嗦一个工具就占了 800 token。5.2 模型忽略了系统指令有时候你会发现模型不遵守人设或者不按格式输出。排查下来往往是因为系统指令被放在了上下文中间或者被大量检索内容淹没了。解决方法是确保系统指令始终在最前面并且用明确的分隔标记把它和后面的内容区分开。另外如果检索内容特别多可以在系统指令末尾再强调一次关键约束形成首尾呼应。注意不要在系统指令里塞太多内容。我见过有人把整个知识库都塞进系统指令结果模型反而抓不住重点。系统指令应该精炼只放最核心的行为约束。5.3 历史对话截断后逻辑断裂用滑动窗口截断历史时经常出现截断点正好在一组问答中间的情况导致模型看到的问题没有对应的回答或者回答没有对应的问题。这会让模型产生困惑。解决办法是截断时保证问答成对。如果历史是以消息列表形式存储的截断时要检查边界确保不会把一对 user-assistant 拆开。具体做法是找到截断位置后向前或向后调整到最近的轮次边界。def align_to_turn_boundary(history, index): # 向前找到最近的 user 消息作为起点 while index 0 and history[index][role] ! user: index - 1 return index5.4 检索内容与问题不相关有时候检索器返回的内容和当前问题关系不大但 ContextBuilder 还是把它拼进去了结果干扰了模型。这个问题严格来说不全是 Builder 的责任但 Builder 可以做一层过滤。我的做法是在收集阶段加一个相关性阈值低于阈值的文档直接丢弃。如果全部低于阈值就干脆不加检索内容让模型基于自身知识回答。这样比强行塞入无关内容要好。5.5 常见问题速查表问题现象可能原因排查方向解决方法上下文超长报错某部分预算失控开启调试模式看各部分占用调整预算或加强裁剪模型忽略人设系统指令位置靠后检查消息顺序确保 system 在最前回答逻辑断裂历史截断破坏问答对检查截断边界对齐到轮次边界回答被无关内容带偏检索内容不相关检查检索相关性分数加阈值过滤工具调用失败工具定义被截断检查工具描述完整性按工具粒度裁剪响应变慢上下文过长统计总 token 数压缩低优先级内容5.6 几个我踩过的坑第一个坑是过度依赖字符数估算 token。早期我图省事用字符数除以 4 来估算结果中文场景下误差能到 30%导致要么浪费预算要么超长。后来老老实实用 tokenizer 精确计算问题就没了。第二个坑是缓存没设上限。token 计数缓存一开始没限制大小跑久了内存一直涨。后来改成 LRU 缓存限制在 10000 条以内内存就稳定了。第三个坑是配置对象被意外共享。多个场景共用同一个配置对象结果一个场景改了配置另一个场景也跟着变。后来改成每次构建时深拷贝一份配置或者用不可变的数据结构问题就解决了。第四个坑是忽略了空内容的情况。有时候检索返回空列表或者历史为空如果代码没做判空就会拼出空的消息有些模型接口会因此报错。所以每个部分加入前都要判空。6. 性能优化与扩展思路6.1 构建性能的优化点ContextBuilder 本身的计算量不大但在高并发场景下token 计数和字符串拼接也会成为瓶颈。我做过一次压测发现单次构建耗时里token 计数占了七成以上。优化方向有几个。第一是缓存前面提到的 LRU 缓存能显著减少重复计算。第二是批量计数如果一次要算多段文本的 token可以合并成一次 tokenizer 调用减少函数调用开销。第三是惰性计算只有在真正需要判断是否超预算时才计算而不是每段都算。def count_batch(self, texts): # 合并后一次编码再按边界切分 combined \n.join(texts) tokens self.tokenizer.encode(combined) # 按换行符对应的 token 切分这里简化处理 return self._split_token_counts(tokens, texts)6.2 支持多模态内容的扩展现在的智能体越来越多地需要处理图片、音频等多模态内容。ContextBuilder 要支持这些需要在消息结构里加入多模态字段。主流模型接口通常支持 content 为列表的形式列表里可以有文本块和图片块。扩展时要注意多模态内容的 token 计算方式和纯文本不同图片通常按分辨率折算成固定的 token 数。所以预算分配时要把这部分单独考虑不能和文本混在一起算。6.3 动态预算调整固定预算在简单场景够用但复杂场景下不同请求的实际需求差异很大。比如有的问题需要大量检索有的问题几乎不需要。动态预算的思路是根据请求特征实时调整各部分预算。实现方式可以是先估算当前输入和系统指令的固定开销剩余预算再按权重分配给工具、检索和历史。权重可以根据问题类型动态调整比如检测到问题里包含查一下资料等词就提高检索预算。这个思路我在一个项目里试过效果不错但实现复杂度也上去了。建议先用固定预算跑通有明确需求再上动态调整。6.4 与多智能体协作的结合在多智能体场景下ContextBuilder 还要考虑智能体之间的消息传递。一个智能体可能需要知道其他智能体的输出这些输出该以什么形式、什么优先级放进上下文是个值得思考的问题。我的做法是把其他智能体的输出当作一种特殊的检索内容放在检索内容之后、历史对话之前。同时给它一个中等优先级在预算紧张时可以裁剪。这样既保证了协作信息的传递又不会挤占核心内容的空间。7. 一些实操心得与建议写到这里核心内容基本讲完了。最后分享几个我在实际项目里总结的小经验都是文档里不会写、但用起来很实在的东西。关于配置管理我强烈建议把配置写成独立的配置文件而不是散在代码里。这样调整预算时不用改代码改配置重启就行。而且配置文件可以纳入版本管理方便追溯每次调整。关于测试ContextBuilder 一定要有单元测试。测试用例要覆盖各种边界情况空历史、空检索、超长输入、预算刚好卡边界等。我写过的测试里最有价值的是那些预算刚好差一点的用例它们能暴露出很多裁剪逻辑的边界 bug。关于日志构建过程的关键决策要打日志比如检索内容从 4 个裁剪到 3 个历史对话从 10 轮裁剪到 8 轮。这些日志在排查线上问题时非常有用能快速定位是哪一步的裁剪导致了效果下降。关于迭代不要一开始就追求完美的预算分配。先用一个粗略的配置跑起来观察实际效果再根据反馈逐步调整。我见过有人花大量时间设计完美的预算方案结果上线后发现实际场景和预想完全不同白费功夫。关于监控上线后要监控上下文的平均长度、各部分占比、裁剪频率等指标。如果发现某部分经常被裁剪说明预算分配不合理需要调整。这些指标能帮你持续优化而不是上线就不管了。ContextBuilder 这个组件看起来简单但真正做好需要对这些细节有深入理解。希望这篇文章能帮你少走一些弯路把这套东西用得顺手。
阅读完成 · 觉得有帮助?