1. 从上下文拼装说起为什么ContextBuilder值得单独拎出来讲做过Agent开发的人都有一个共同体会模型能力再强如果喂给它的上下文是乱的输出质量会断崖式下跌。很多人把精力花在选模型、调温度、写提示词上却忽略了一个更底层的问题——上下文是怎么被组装出来的。在Hello-Agents这个项目里ContextBuilder就是专门解决这个问题的模块。它的职责说起来简单把系统提示、历史对话、工具调用结果、外部检索内容、用户当前输入等零散信息按照一定规则拼装成一份结构清晰、长度可控、语义连贯的上下文再交给大模型。但真正动手写过的人都知道这件事的复杂度远超字符串拼接。我最初接触这个模块时也犯过嘀咕不就是把几段文本接在一起吗能有多难后来在实际项目里踩了几轮坑才明白ContextBuilder要处理的问题包括但不限于多轮对话中历史消息的截断策略、工具返回结果的格式归一化、不同来源内容的优先级排序、token预算的动态分配、以及当上下文超长时的降级方案。每一个点单独看都不算难但组合在一起就变成一个需要认真设计的子系统。这篇内容适合三类人看一是正在做Agent应用、被上下文管理折磨过的开发者二是想理解Agent内部运转机制、不满足于只调API的技术爱好者三是准备在自己的项目里引入类似ContextBuilder思路的工程师。我会围绕Hello-Agents中这个模块的设计思路、核心机制、实操细节和踩坑经验展开尽量把为什么这么设计讲透而不是只丢一段代码让你抄。需要提前说明的是Hello-Agents是一个用于学习和实验Agent架构的项目ContextBuilder在其中的定位是上下文组装层。不同版本的实现细节可能有差异我下面讲的是基于常见实践和我自己复现时的做法具体以你手上的代码为准。2. ContextBuilder在Agent链路中到底扮演什么角色2.1 一次完整的Agent调用里上下文经历了什么要理解ContextBuilder的价值得先看清楚一次Agent调用中上下文的流转过程。假设用户问了一个需要调用工具的问题典型的链路是这样的用户输入进入系统被包装成一条user消息系统需要决定是否调用工具此时要把系统提示、历史对话、当前输入一起送给模型模型返回工具调用请求系统执行工具拿到结果工具结果需要被包装成模型能理解的格式再次拼进上下文模型基于更新后的上下文生成最终回复在这个流程里第2步和第4步都涉及上下文组装。如果没有ContextBuilder这两步的代码会散落在业务逻辑各处每加一种新消息类型就要改一遍拼接逻辑维护成本极高。ContextBuilder把这些逻辑收敛到一个模块里对外只暴露给我一组消息我还你一份可用的上下文这样的接口。我在自己的项目里最初就是到处手写拼接结果加了检索功能之后历史消息、检索片段、工具结果三种来源的格式各不相同模型经常把检索内容当成用户说的话闹出不少笑话。后来把组装逻辑抽出来统一管理这类问题才消停。2.2 它和Prompt模板、Memory模块的边界在哪这里有个容易混淆的点ContextBuilder和Prompt模板、Memory模块看起来都在处理文本它们的区别是什么我的理解是这样Prompt模板关注的是单条消息怎么写比如系统提示的措辞、few-shot示例的排列Memory模块关注的是哪些信息值得长期保留比如用户偏好、历史事实的存储与检索而ContextBuilder关注的是这一轮到底把哪些内容、以什么顺序、塞进有限的token预算里。三者是协作关系不是替代关系。举个具体场景Memory模块检索出三条相关历史事实Prompt模板定义了系统提示的写法但最终这三条事实放在系统提示后面还是用户消息前面、要不要加分隔标记、超长了先砍谁——这些决策都是ContextBuilder做的。它更像是一个调度中心而不是内容的生产者。2.3 为什么说它是Agent稳定性的隐形支柱很多人评估Agent质量时只看最终回答但回答不稳定往往不是模型的问题而是上下文组装不稳定。同样的用户输入如果这次历史消息截断在第三轮、下次截断在第五轮模型看到的信息不同输出自然飘忽。ContextBuilder通过固定组装规则让相同输入产生相同上下文成为可能。这一点在调试时尤其重要——当你能确定模型每次看到的上下文是一致的才能把问题定位到模型本身还是上下文构造上。我在排查一个模型偶尔答非所问的问题时最后发现是工具结果里混入了时间戳导致每次上下文都有细微差异模型被这些噪声干扰了判断。如果没有ContextBuilder统一管理这种问题几乎无从查起。3. 拆开看ContextBuilder的内部构造3.1 消息对象的标准化一切从统一数据结构开始ContextBuilder要处理的第一个问题是不同来源的信息格式五花八门。用户输入是纯文本工具返回可能是JSON检索结果是带score的片段历史消息可能是带role的字典。如果直接拼接模型看到的是一锅乱炖。常见做法是先定义一个统一的消息结构通常包含这几个字段字段含义是否必需role消息角色如system、user、assistant、tool是content消息正文是name来源标识用于区分同名角色否metadata附加信息如时间戳、来源、score否token_count预估token数用于预算控制否把所有输入先转成这个结构后续的排序、截断、渲染才有统一的依据。这一步看起来是体力活但它是整个模块可靠性的地基。我见过不少项目跳过这一步直接在字符串层面操作结果每加一种消息类型就要写一堆特判代码很快就失控了。提示metadata字段建议只放对模型无用的辅助信息不要把时间戳、内部ID这类内容混进content否则会像前面说的那样引入噪声。3.2 组装顺序的讲究谁在前谁在后不是随便定的消息顺序对模型理解的影响比很多人想象的要大。一个经过验证的相对稳妥的顺序是系统提示定义角色、能力边界、输出格式长期记忆或用户画像如果有检索到的外部知识标注来源与对话区分历史对话按时间正序工具调用记录与对应的assistant消息配对当前用户输入这个顺序背后的逻辑是越靠前的内容越像背景设定越靠后的越像当前任务。模型在处理时靠近末尾的内容往往获得更多注意力所以当前输入放最后符合直觉。但这不是铁律。如果你的场景里检索内容比历史对话更重要可以把检索片段往后放。关键是保持一致性不要这次这样排下次那样排否则模型的行为会变得难以预测。3.3 token预算的分配先算账再花钱上下文窗口是有限资源ContextBuilder必须像一个精打细算的管家。常见做法是给每类内容分配一个预算上限比如系统提示固定占用通常几百token检索内容不超过总预算的30%历史对话不超过40%工具结果不超过20%当前输入预留10%以上这些比例不是拍脑袋定的而是根据实际场景调整。如果发现模型经常忽略检索内容可以适当提高检索占比如果多轮对话的连贯性更重要就加大历史对话的份额。计算token时要注意不同模型的tokenizer不一样中文和英文的token比例也不同。粗略估算时中文大约1个字对应1到2个token英文大约1个单词对应1到1.5个token。精确计算需要调用对应模型的tokenizer但在预算规划阶段用估算值就够了。3.4 超长时的降级策略砍谁、怎么砍当所有内容加起来超过预算时ContextBuilder需要做取舍。这里有几个层次的策略从温和到激进压缩把冗长的工具结果摘要化只保留关键字段截断对历史对话从最旧的开始丢弃但保留最近N轮合并把多条相似的历史消息合并成一条概括丢弃直接放弃优先级最低的内容我个人的经验是优先保留最近3到5轮对话和当前输入这是模型理解当前任务的最小必要信息。检索内容如果超预算按score排序保留高分片段。工具结果如果太长只保留状态码和关键返回值把详细内容放到日志里备查。注意截断历史对话时不要从中间切断一条消息否则模型可能看到半句话产生误判。要么整条保留要么整条丢弃。4. 动手实现一个可用的ContextBuilder4.1 最小可用版本的代码骨架下面是我自己复现时用的一个简化版本用Python写去掉了具体项目的依赖你可以直接拿去改class Message: def __init__(self, role, content, nameNone, metadataNone): self.role role self.content content self.name name self.metadata metadata or {} self.token_count self._estimate_tokens(content) def _estimate_tokens(self, text): # 粗略估算中文按1.5英文按1.3 chinese sum(1 for c in text if \u4e00 c \u9fff) others len(text) - chinese return int(chinese * 1.5 others * 0.3) class ContextBuilder: def __init__(self, max_tokens8000): self.max_tokens max_tokens self.system_prompt None self.history [] self.retrievals [] self.tool_results [] def set_system_prompt(self, text): self.system_prompt Message(system, text) def add_history(self, role, content): self.history.append(Message(role, content)) def add_retrieval(self, content, score0.0): self.retrievals.append((score, Message(system, content, nameretrieval))) def add_tool_result(self, content, tool_name): self.tool_results.append(Message(tool, content, nametool_name)) def build(self, user_input): messages [] if self.system_prompt: messages.append(self.system_prompt) # 检索内容按score排序 sorted_retrievals sorted(self.retrievals, keylambda x: -x[0]) for _, msg in sorted_retrievals: messages.append(msg) # 历史对话 messages.extend(self.history) # 工具结果 messages.extend(self.tool_results) # 当前输入 messages.append(Message(user, user_input)) return self._fit_budget(messages) def _fit_budget(self, messages): total sum(m.token_count for m in messages) if total self.max_tokens: return messages # 超预算从历史对话开始丢弃 result list(messages) # 保留系统提示和当前输入 protected {0, len(result) - 1} idx 1 while total self.max_tokens and idx len(result) - 1: if idx not in protected: total - result[idx].token_count result.pop(idx) else: idx 1 return result这段代码能跑但只是骨架。真实场景里你还需要处理工具调用和结果的配对、多模态内容、以及更精细的预算分配。4.2 工具结果格式归一化的实操细节工具返回的内容格式千奇百怪有的返回JSON有的返回纯文本有的带一堆嵌套字段。直接塞给模型它可能抓不住重点。我的做法是写一个归一化函数把工具结果转成状态 关键信息 可选详情的三段式def normalize_tool_result(raw_result, tool_name): if isinstance(raw_result, dict): status raw_result.get(status, unknown) key_info raw_result.get(data) or raw_result.get(result) detail raw_result.get(detail, ) else: status success key_info str(raw_result) detail content f[工具:{tool_name}] 状态:{status}\n结果:{key_info} if detail: content f\n详情:{detail[:200]} return content这样处理之后模型看到的是结构一致的内容理解成本降低。详情部分做长度限制避免单个工具结果吃掉太多预算。4.3 历史对话的滑动窗口与摘要结合纯滑动窗口的问题是丢掉的历史信息就彻底没了。如果用户在前面提到过关键约束后面又被截断模型就会失忆。一个折中方案是保留最近N轮完整对话更早的内容用一段摘要代替。摘要可以由模型生成也可以用规则提取关键实体。我倾向于在对话轮次达到阈值时触发一次摘要把摘要作为一条特殊的system消息放在历史对话前面。这样既控制了长度又保留了长期信息。具体阈值设多少取决于你的场景。对话轮次少、每轮内容短的场景可以设大一点比如10轮如果每轮内容很长可能5轮就该摘要了。4.4 预算计算的精度与性能权衡前面用的是估算token优点是快缺点是误差可能到20%以上。如果预算卡得很紧估算误差会导致实际超限。更精确的做法是调用模型的tokenizer但每次组装都调用会有性能开销。我的折中方案是日常组装用估算当估算值接近预算上限比如达到90%时再用精确tokenizer复核一遍。这样大部分情况下走快速路径只有边界情况才付出精确计算的成本。5. 那些文档里不会写的踩坑记录5.1 工具调用与结果必须成对出现这是我最开始踩的坑。模型发起工具调用后assistant消息里包含tool_calls字段紧接着应该有一条tool角色的消息返回结果。如果ContextBuilder在截断时把assistant消息丢了却保留了tool结果或者反过来模型会直接报错或者产生混乱输出。解决办法是在截断逻辑里把assistanttool视为一个不可分割的单元要么一起保留要么一起丢弃。实现上可以给这类消息打个group标记截断时按组操作。5.2 检索内容的来源标注不能省早期我图省事把检索到的片段直接当普通文本塞进去结果模型经常把检索内容当成用户说过的话回答时来一句正如您刚才提到的……其实用户根本没提过。后来在每条检索内容前加上明确的来源标记比如[参考资料]或[知识库片段]这类问题就基本消失了。标注方式不用太花哨关键是让模型能区分这是外部知识和这是对话内容。有些实现会用特殊的role或者分隔符效果也可以。5.3 空内容和None值的处理工具返回空结果、检索没命中、历史消息里有空字符串——这些边界情况如果不处理轻则浪费token重则让模型困惑。我的做法是在归一化阶段就把空内容过滤掉如果某类内容全部为空干脆不往上下文里放。还有一个隐蔽的坑有些工具返回的是null字符串而不是None如果不小心处理模型会看到一个字面的null可能被误导。归一化时统一转成无结果之类的自然语言描述更稳妥。5.4 多轮对话中角色交替的隐性约束部分模型对消息角色的交替有要求比如user和assistant必须交替出现不能连续两条user。ContextBuilder在组装时如果没注意可能拼出连续两条user消息导致API报错。处理办法是在组装完成后做一次校验发现连续同角色消息就合并或者插入占位。这个校验逻辑不复杂但很容易被忽略尤其是在拼接检索内容和用户输入的时候。6. 让ContextBuilder更好用的几个进阶思路6.1 按场景切换组装策略不是所有对话都需要同样的组装方式。闲聊场景可能不需要检索任务型场景可能工具结果占大头。可以给ContextBuilder加一个模式参数不同模式下用不同的预算分配和排序规则。比如问答模式下检索内容优先执行模式下工具结果优先闲聊模式下历史对话优先。这样一套代码能适配多种场景不用为每个场景写一套组装逻辑。6.2 把组装过程记录下来便于复盘ContextBuilder每次组装完可以把最终的消息列表和token占用情况写进日志。出问题时翻日志能快速定位是内容没进来还是进来了但被截断了。我在排查一个模型忽略关键信息的问题时就是靠日志发现那条信息在截断阶段被丢掉了。日志不用记全部内容记消息的角色、来源、token数和截断决策就够了。敏感内容可以做脱敏处理。6.3 预留扩展点应对多模态现在很多场景需要处理图片、音频等多模态内容。ContextBuilder在设计时最好预留扩展点比如消息的content字段允许是列表而非纯字符串每种模态有自己的token计算方式。这样以后加多模态支持时不用推翻重来。我自己的做法是把content统一抽象成内容块列表文本块、图片块各有自己的结构渲染时再根据模型支持的格式转换。前期多花一点设计成本后期扩展会轻松很多。6.4 用测试用例锁定组装行为ContextBuilder的行为一旦确定就应该用测试用例固定下来。比如给定3轮历史2条检索1个工具结果输出应该是什么顺序、多少token写成断言。这样后续改代码时如果不小心改变了组装行为测试会立刻报警。我吃过这个亏一次重构把检索内容的排序逻辑改错了从按score降序变成了升序结果低质量片段被优先送入回答质量下降了好几天才发现。有了测试用例这种问题在提交前就能拦住。7. 我在实际项目里的一些体会ContextBuilder这类模块的特点是做好了没人夸做砸了到处是问题。它不像模型选型那样有立竿见影的效果但它是Agent稳定运行的底座。我现在的习惯是每接入一个新的内容来源第一件事就是把它纳入ContextBuilder的统一管理而不是临时拼一段字符串了事。另外一点体会是不要追求一次设计到位。上下文组装的策略会随着你对场景理解的深入而调整一开始用最简单的规则跑通然后在实际使用中逐步优化预算分配和截断策略比一开始就设计一套复杂机制要务实得多。我见过有人花两周设计了一套精密的优先级算法结果上线后发现场景根本用不上那么复杂反而增加了维护负担。最后分享一个小技巧如果你不确定某种组装顺序好不好可以做个A/B对比用同一批问题分别跑两种组装方式看输出质量的差异。这种土办法虽然不优雅但比拍脑袋决策靠谱得多。