做过大模型应用开发的人应该都体会过一种微妙的尴尬上下文越堆越厚模型却越变越笨。我维护的Hello-Agents是一个专门用来演示Agent内部机制的示例项目名字有点致敬Hello World的意思定位就是让刚入门的人通过跑通一个最小Agent看清它的运转方式。前几个版本里它一直是单工具、单轮对话上下文拼装很随意没人觉得有问题。等我把天气查询和备忘记录两个工具同时接进去问题一下就炸了。9.3版本开始我下决心把这块彻底重构核心产出就是一个专门负责组装上下文的组件ContextBuilder。这篇文章会把我在Hello-Agents 9.3中设计、接入、调优ContextBuilder的完整过程写出来包括它到底解决什么问题、内部四个核心抽象怎么设计、接入运行时有哪些关键步骤以及我实测中踩过的几个印象深刻的坑。如果你正在给Agent项目做上下文治理或者想建立一套统一的消息构建机制这篇应该能帮你少走不少弯路。1. 从一次Prompt杂乱事故说起ContextBuilder要解决的真实问题我先讲个真实翻车现场。那天我给Hello-Agents同时接上了天气查询工具和备忘记录工具然后输入了一句很普通的话“今天上海天气怎么样顺便帮我记下来。”按照预期模型应该先调用天气工具拿到“多云25摄氏度”再调用备忘工具把这句“今天上海天气多云25摄氏度”写进待办。结果模型在拿到天气结果之后居然把这句“多云25摄氏度”当成了用户的新指令又去触发了一次备忘写入甚至开始分析用户为什么要记录天气数据整段回复逻辑混乱到没法看。我打开日志一层层排查发现问题根本不在模型而在上下文组装环节。系统提示词末尾被某个模块塞了一段工具调用示例历史消息被复制了一份放在用户输入后面工具返回的结果又被拼到了系统提示词的中间位置。这些内容全混在一起模型自然分不清哪条是系统约束、哪条是历史记录、哪条是工具真实返回。更隐蔽的是每个模块在单独运行时都正常一旦多个模块同时往消息列表里塞内容彼此之间完全没有任何协调。这件事给我最直接的教训是上下文组装这种“边缘逻辑”一旦散落在各个模块里Agent一复杂就会变成灾难。每个模块都觉得自己只是往消息列表里追加了一句话合在一起就是一团浆糊。于是在9.3版本我做了一个明确决定——把“收集上下文”和“组装上下文”这两件事彻底收拢到同一个组件里这个组件就是ContextBuilder。ContextBuilder的目标并不是发明什么新范式而是让上下文构建变得可编排、可审计、可调优。它把每次对话需要进入模型视野的一切内容统一管理起来系统提示词、工具定义、历史会话、用户输入、工具返回结果以及将来可能加入的检索资料全部通过同一套标准机制进入同一个上下文包。Hello-Agents 9.3之后所有模块不再直接碰消息列表而是向ContextBuilder声明“我有什么上下文可以贡献”由它来决定顺序、配额和最终形态。看完这个事故背景你应该能理解为什么一个Builder组件值得单独写一篇。接下来我拆开说它的核心设计。2. 9.3里ContextBuilder的核心设计四大抽象ContextBuilder如果只做一个拼字符串的方法那没有意义。真正让它能扛住多工具、多轮对话的关键是四个相互配合的核心抽象。我一个个说。2.1 ContextSource一切上下文都是“数据源”我做的第一件事是把“上下文内容”从各业务模块中解耦出来。所有能产生上下文的模块统一实现一个接口public interface ContextSource { String namespace(); ListContextItem build(AgentRequest request); }namespace返回这个来源的命名空间比如system、tools、history、user、toolResults、rag。build方法根据一次AgentRequest产出若干ContextItem。ContextItem是最小上下文单元包含全局唯一的key、具体内容和一个可选优先级。在Hello-Agents 9.3里我抽出了六种内置SourceSourcenamespace贡献内容SystemPromptSourcesystem角色设定、行为约束ToolDefinitionSourcetools每个工具的JSON Schema描述HistorySourcehistory最近N轮对话消息UserInputSourceuser当前用户输入ToolResultSourcetoolResults工具调用后的真实返回值RagSourcerag检索到的外部知识片段这个抽象的核心价值是依赖倒置。业务模块不需要关心自己处在消息列表的第几条只需要声明“我有这些内容”。顺序和取舍完全交给BuildPlan去管模块之间互不感知。后来我又加了EnvInfoSource这类自定义Source也不需要改动任何核心代码都是靠这个接口做到的。2.2 ContextKey与命名空间防止字段互相污染设计ContextItem时我最在意的就是全局唯一key。很多自己拼上下文的代码翻车最后都栽在字段互相覆盖上。比如A模块在context里放了一个userB模块也放了一个user谁后执行谁就覆盖谁这种bug极其隐蔽。所以每个ContextItem的key必须是namespace . field这种形式例如system.role、user.input、tools.definitions。ContextBuilder在组装过程中会做一次key冲突检测如果同一个key出现两次默认按优先级覆盖同时打印一条warning日志提示你有两个Source在争抢同一字段。命名空间相当于给每个模块划了一块独立地盘之后无论怎么加模块都不至于互相踩脚。实际维护中我还遇到过另一种情况同一个Source在build方法里生成了两个key相同的ContextItem。这种自我冲突看起来是小概率事件但出现时模型看到的上下文会静默少掉一条影响同样不小。所以key冲突检测一定要在构建入口统一做不要指望各个Source自己自律经验告诉我们自律在这个场景下几乎不可靠。2.3 BuildPlan声明式描述组装顺序与Token预置有了Source还需要描述它们怎么配合这就是BuildPlan。它很像一份上下文组装的路由表声明哪个Source参与构建、按什么顺序排列、权重多少、最多分配多少Token。BuildPlan plan BuildPlan.builder() .add(SystemPromptSource.class, 0, 0.20, 1200) .add(ToolDefinitionSource.class, 1, 0.15, 800) .add(HistorySource.class, 2, 0.30, 1500) .add(UserInputSource.class, 3, 0.25, 1300) .add(ToolResultSource.class, 4, 0.10, 500) .build();我刻意把BuildPlan设计成声明式的而不是硬编码在Agent里原因是不同场景需要的上下文结构完全不同。闲聊场景可能History权重更高工具密集型场景ToolDefinition和ToolResult更重要RAG问答场景还要给检索片段让出空间。如果这些规则全用if-else写在Agent入口过两周自己都看不懂。用Plan配置相当于把“不同场景的策略”变成了可替换的配置项切换成本极低。2.4 FinalContext统一输出与审计所有Source构建完毕后ContextBuilder输出一个FinalContext对象它包含最终按顺序排列好的消息列表、总Token数以及一份sourceReport审计报告。public class FinalContext { private final ListContextMessage orderedMessages; private final int totalTokens; private final ListSourceReportItem sourceReports; }sourceReport会记录每个Source最终贡献了多少字符、估算Token数、实际内容条数。别小看这个审计能力后面调优Token预算全靠它。没有这份报告你只能靠猜来判断哪个模块吃Token太多。有了它每次请求结束都能看到完整账本哪个Source超预算、哪个Source实际占用远低于预算一眼就能看清。3. 实装步骤把ContextBuilder接进Hello-Agents设计是一回事接进真实项目又会踩到另一批细节。下面是我在Hello-Agents 9.3里完整的接入路径照着做基本能复现。3.1 环境准备我的Hello-Agents工程基于Java 17编写核心不依赖任何第三方框架。模型调用部分做了ModelClient接口我本地测试时接的是一个开源量化模型你完全可以替换成自己正在用的任何推理服务。ContextBuilder本身只依赖JDK标准库这样无论你后续接什么模型服务都不会被某个特定SDK绑死。工程结构上我把ContextBuilder相关类放在core包下把演示用的Source实现放在demo包下。这样区分有实际考量ContextBuilder的核心机制具备通用性而具体Source是与业务绑定的混在一起会污染内核。之后再有人想把ContextBuilder搬到他自己的项目里只需要复制core包不用带着一堆演示代码走。3.2 实现一个自定义ContextSource内置的六个Source覆盖了大多数需求但每个Agent迟早会有自己的额外信息。以Hello-Agents为例我加了EnvInfoSource把当前时间、时区、运行模式注入上下文public class EnvInfoSource implements ContextSource { Override public String namespace() { return env; } Override public ListContextItem build(AgentRequest request) { String content current time: LocalDateTime.now() , timezone: ZoneId.systemDefault() , mode: AppConfig.getMode(); return List.of(new ContextItem(env.runtime, content, 0)); } }很多人会忽略这类“运行时环境信息”但模型本身并不知道现在是几点、今天是星期几。你如果让它回答“今天适合做什么”它没有时间基准只能瞎编。把时间和日期明确放进上下文能明显减少时间幻觉。这是我在实际测试中验证过的加了EnvInfoSource之后涉及日期计算的回答准确率高了一个台阶。3.3 配置BuildPlan并注册接入第二步是把所有Source注册进一个BuildPlan。我在Hello-Agents里默认使用这个顺序System最先Tools第二History第三UserInput第四ToolResults第五。顺序是反复试出来的不是拍脑袋定的。为什么工具定义要排在用户输入之前因为工具定义是“接口约束”模型需要先知道系统能干什么再去看用户具体要什么。如果用户输入很长很复杂工具定义放在后面容易被长文本稀释模型对工具格式的感知会变弱。History放在UserInput之前是为了让模型先回顾上下文再理解当下问题。ToolResults则不应该堆在最后而应该紧跟对应的assistant调用消息之后这个细节我后面踩坑部分会再展开。3.4 在Agent入口统一构建上下文接入的关键一步是让Agent的chat方法彻底不再直接拼消息public AgentResponse chat(String userText) { AgentRequest request AgentRequest.of(userText); FinalContext ctx contextBuilder.build(defaultPlan, request); String reply modelClient.complete(ctx.toMessages()); if (ctx.needToolCalling()) { // 执行工具调用把结果通过ToolResultSource注入重新build一次 } return AgentResponse.of(reply); }改动完成后Agent入口只依赖两个对象BuildPlan和ContextBuilder。Builder根据Plan收集Source内容再按顺序组装出FinalContext。模型调用方拿到的永远是一份有序的、经过预算控制的消息列表。业务模块原来那些“直接往消息里塞内容”的代码全部被迁移到对应Source的build方法里。这里我要特别强调一点改造的重点不是写一个builder类而是让所有模块都停止直接操作消息列表。哪怕你只把消息列表拼接集中到一个类里各业务模块仍然绕开它去改列表那这个集中就没有任何意义。我在code review时花了很大力气去抓这类绕过行为这个环节比写代码本身更考验治理决心。3.5 验证与调试接完之后一定要做一次可见的输出对比。我在测试环境里打开了ContextBuilder的debug模式让它把sourceReport打印出来。一次典型输出大致长这样[ContextBuilder] sourcesystem tokens680 items1 [ContextBuilder] sourcetools tokens512 items2 [ContextBuilder] sourcehistory tokens1024 items6 [ContextBuilder] sourceuser tokens180 items1 [ContextBuilder] sourcetoolResults tokens240 items1 [ContextBuilder] total tokens before output 2636对照这份报告我很快发现HistorySource占了将近一半预算而实际上那轮对话的前三轮历史对当下回答没有任何帮助。这直接推动我调整了窗口策略从保留最近5轮改成最近3轮加一个摘要字段。如果没有sourceReport这种低效占位会在很晚才暴露。所以接完ContextBuilder后的第一条建议就是打开审计报告跑一轮真实请求看看数据是不是符合你的直觉。4. Token预算与裁剪策略实测中的关键调优ContextBuilder接入只是第一步真正让它在实际使用里站住脚的是Token预算控制和超限裁剪。这一章讲的是我实测后沉淀下来的方法。4.1 Token预算为什么不能拍脑袋模型的上下文窗口是稀缺资源你不能让所有Source无限往里面塞。但只凭感觉设一个固定阈值又会遇到两种情况预算太松上下文容易塞满无关内容模型注意力被稀释预算太紧关键的SystemPrompt和工具定义被挤掉模型行为直接失序。这两种情况我都经历过都不好受。我的做法是先算出一个总的可用上下文预算再按权重分给各Source。计算公式可以简化成usableWindow modelContextWindow × safetyRatio reservedForOutput 模型回答预留Token availableForContext usableWindow - reservedForOutputsafetyRatio我默认设0.75原因是模型本身会有内部格式开销比如特殊token、消息角色标记。给输出预留多少取决于你的模型配置Hello-Agents里我按1024来算。假设模型上下文窗口是8192那么availableForContext大约是8192乘0.75减1024约5120Token。这个数才是我们能分给各个Source的总盘子。4.2 按权重分配各Source Token预算总盘子确定后按BuildPlan里记录的权重系数做一次比例分配sourceBudget (weight_i / sum(weights)) × availableForContext以默认权重为例可用5120Token时分配结果大致如下Source权重分配TokenSystem0.201024Tools0.15768History0.301536UserInput0.251280ToolResults0.10512这里有一个重要提醒HistorySource的预算并不代表它必须用满。它的实际消耗应该按“保留最近几轮消息”来估算而不是按预算贪心填充。如果最近几轮消息确实没那么多多出来的预算宁可留给其他Source也不要硬塞历史。我一开始就是没想明白这点结果History总是贴着上限走白白浪费窗口。4.3 超限裁剪的两种策略截断与重写摘要不管预算怎么分总会有Source内容超限的情况。我在Hello-Agents里实现了两种可插拔策略默认用截断可选摘要重写。截断策略的思想很简单从最不重要的内容开始丢弃。对于HistorySource就是从最早的消息开始丢保留最近几轮对于RagSource就是丢相关度最低的片段。摘要重写策略适合异步场景当超限的部分实在太重要、不能简单丢弃时调用一次轻量模型把超限内容压缩成摘要。代价是额外延迟和成本所以我不建议把它放到每次请求的同步链路上。更稳妥的做法是先截断把截掉的部分记录到审计日志事后离线分析这些被截掉的内容是否影响了回答质量。如果发现某些Source经常被截断、且确实影响了效果再考虑单独调大预算而不是盲目上摘要。4.4 实测效果对比我在Hello-Agents 9.3上跑了一组对照测试用同一批30个多工具任务分别测了三种配置配置工具调用准确率平均有效回答Token明显失败率无预算控制73%148020%固定截断83%126013%权重预算截断91%10907%数据规模不算大但趋势是明确的。无预算控制时上下文里混入了大量无关历史模型经常抓错重点固定截断虽然缓解了超长问题却没有区分Source重要性偶尔把SystemPrompt截得没法看权重预算方案相当于给每种内容划了“地界”模型终于能稳定拿到它最需要的那部分信息。这个对比也验证了一个观点预算控制的价值不只在省Token更在于集中模型的注意力。5. 我在Hello-Agents 9.3中踩过的几个坑这章可能比前面的设计更值得看因为每个坑我都真实遇到过而且排查起来都很费劲。5.1 上下文字段互相覆盖第一次出现这个问题时模型会突然“忘记”用户输入回答里不断重复工具定义中的示例。我排查了大半天才找到原因两个Source都在自己的build方法里放了key为user的ContextItem后执行的Source覆盖了前一个。由于两个模块单独测试都正常联调才暴露。后来我在ContextBuilder入口加了key冲突检测重复key直接抛warning并强制所有key带namespace前缀。有这个机制之后字段互相覆盖基本很难再发生了。5.2 历史消息重复注入导致Token翻倍有一次我打开sourceReport发现HistorySource明明只保留了6条消息totalTokens却比预期多了接近一倍。逐条对比消息内容原来是UserInputSource在处理多轮对话时为了给模型提供“当前问题的上下文”又把上一轮的内容重新放进了User消息。History里有一份User里又有一份两边的token都真实计入请求。解决方式是在ContextBuilder最终组装前做一次消息归一化去重优先保留带messageId的消息没有messageId的则按role加内容哈希去重。从那以后我把“所有历史统一进HistorySource”写成了约定不允许其他Source私自保存历史。5.3 工具定义与工具结果拼接位置影响模型判断这是我在9.3开发中印象最深的一个坑。最初BuildPlan里我把ToolDefinitionSource放在UserInput之后理由是“让模型先看完用户需求再看工具”听起来很合理。但实际测试发现只要用户输入稍微复杂模型对工具参数的理解就开始飘。后来我把工具定义挪到UserInput之前问题立刻缓解了。另一个相关问题是工具结果的位置如果所有工具结果统一堆在消息列表末尾而模型的工具调用发生在中间模型需要跨过很长一段文本才能把结果和调用关联起来。正确做法是让ToolResult紧跟对应的assistant调用消息之后模拟真实对话流。5.4 并发构建时的状态污染Hello-Agents本身是个示例项目并发量不高但我压测时还是遇到了一个诡异问题两个并发请求同时修改同一个BuildPlan实例一个请求改了History的token上限另一个请求的上下文也跟着变了。原因很简单我当时把BuildPlan设计成了可变对象又想省事在service里用了一个共享的plan变量。修复方式是让BuildPlan完全不可变所有配置项在build后都不可修改需要不同场景配置就新建Plan。ContextBuilder本身只依赖不可变Plan和纯函数式的Source调用链线程安全就有了保障。这个坑提醒我凡是会被多个请求共享的配置对象第一原则就是不可变。6. 下一步把ContextBuilder变成可观测的上下文管线如果只实现到上面那一步ContextBuilder已经能帮项目稳定扛住多工具场景了。但我用了一段时间后发现它还可以再往前跨一步变成真正可观测的上下文管线。6.1 记录每次构建的上下文快照每次构建完FinalContext我把关键信息存了一份快照原始用户输入、最终进入模型的消息列表、每个Source的预算与实际消耗、是否发生了截断。积累几百条之后做离线统计你能非常清楚地看到哪个Source在浪费Token、哪种场景经常触发截断。这个能力让“调优”不再是拍脑袋而是看着数据做决策。6.2 按场景动态切换BuildPlan现在Hello-Agents会根据输入做一个简单的意图判断选择不同的BuildPlan。闲聊场景用高History权重、低Tools权重工具密集场景用高Tools权重、限制HistoryRAG问答场景则把RagSource放到更靠前的位置。每个Plan本质上就是一套独立的上下文策略切换Plan并不会影响各个Source实现这是当初把策略声明式化的红利。我后来在实践中发现这种按场景拆Plan的方式比在一个Plan里塞满所有Source要清晰得多。6.3 对RAG检索结果做重要性排序最后说一下RagSource的细节。检索结果不能简单按相似度分数排序后一股脑塞进去。我在实际使用中发现有些相似度很高的片段其实已经过时参考价值反而不如一条时间更新、相似度稍低的片段。所以在RagSource里我做了一个融合排序相似度占大部分权重时效性做加分项命中的用户实体做额外加权。排序后的TopK再进入上下文模型回答的信息质量明显更稳。最后再分享一个小技巧无论你把ContextBuilder写得多么完善一定要保留原始输入与最终构建结果的对照日志。上下文构建机制越复杂出问题时的排查就越依赖这条日志链。每次看到异常回复我先对比快照里模型到底看到了什么再判断是Source内容脏了还是Plan顺序错了。这套“先看上下文、再猜模型行为”的排查思路比对着prompt猜半天高效得多。希望这篇Hello-Agents 9.3的实践记录能让你在设计自己的上下文构建层时少踩几个类似的坑。