恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

ContextBuilder:多轮对话Agent上下文管理组件的设计与实践

  • 首页
  • 资讯中心
  • /
  • ContextBuilder:多轮对话Agent上下文管理组件的设计与实践

相关资讯

AI论文降AIGC率工具实测:八款去AI味软件横向测评 2026/10/11 4:57:02
气象数据分析全流程:从数据清洗到可复现报告的资源包拆解 2026/10/11 4:57:02
判定表驱动法实战:从漏测事故到自动化用例设计 2026/10/11 4:57:02

最新资讯

人形机器人全身跟踪算法框架与训练范式工程综述
MySQL JSON 类型实战:存取、查询函数、生成列索引与 8.0 多值索引全解
OpenDots开源视觉推理框架:本地部署、微调与多轮对话实战指南
SpringBoot+Vue+MyBatis大创管理系统源码设计与实战解析
金融报告智能体Harness理念与实操落地指南
多智能体非中心化安全控制:DMPC实战落地指南

今日推荐

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本周热门

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

ContextBuilder:多轮对话Agent上下文管理组件的设计与实践

发布时间:2026/10/11 4:57:02
ContextBuilder:多轮对话Agent上下文管理组件的设计与实践 在Hello-Agents的9.3实践里我大部分精力都花在一个叫ContextBuilder的组件上。这个组件名字听起来不复杂做的事却是Agent项目里最容易被低估的一环把系统提示词、多轮历史消息、工具调用返回结果、临时记忆片段按规则组装成模型能直接消费的上下文。很多人做多轮对话Agent做到一半会卡在同一种状态——对话轮次一多模型开始“失忆”或者工具返回的内容把上下文搅得一团糟或者随便拼接的字符串让API直接报格式错误。这篇文章把我在9.3里设计、实现、调优ContextBuilder的完整过程梳理出来包括核心数据结构、组装逻辑、接入对话循环的方式以及我实际踩过的一些坑希望能给正在做类似项目的朋友省点时间。1. 为什么需要ContextBuilder1.1 Hello-Agents的9.3实验到底在做什么Hello-Agents是一个偏教学性质的Agent实践项目9.3这一节的核心是“多轮对话下的工具调用”。前半部分章节都在解决单轮调用和简单Prompt编排到了9.3问题突然变得复杂起来模型需要基于用户的多条历史消息做判断需要调用外部工具还要根据工具返回结果继续推理。这个闭环如果只靠手写字符串拼接很快就会失控。ContextBuilder就是为这个章节专门设计的上下文构造器。它不是一个花哨的框架更像一个“上下文车间”所有信息进来之后经过清洗、排序、截断、格式化最后输出一份结构清晰的messages数组交给模型。我在实现它之前先做了个简单梳理发现整个Agent循环其实可以看作一个状态机——用户输入进来模型决定是否需要工具如果需要就执行工具并把结果写回上下文再让模型继续。而这个循环里最核心的问题只有一个每次调用模型时你喂给它的上下文到底应该包含什么。没有这套约束代码可能能跑通最简单的两三轮对话但一旦加上工具调用、加上历史记录、加上系统级的指令约束问题就会被放大。ContextBuilder要解决的正是这类规模化问题这也是9.3把它单独拎出来作为实践主题的原因。1.2 没有ContextBuilder时上下文到底乱在哪我在做9.3之前用过最原始的方式把所有消息塞进一个list每次调用模型时把它们全部拼起来。前几轮还正常到第五轮左右就开始出现各种奇怪问题。最常见的现象是模型“失忆”。它明明在前面对话里说过某个关键信息但到后面需要这个信息时完全没反应。原因很简单——上下文超长之后我用了最简单的“取尾部N条”策略早期关键信息直接被截掉了。更麻烦的是工具返回结果。我一开始把工具返回的JSON结构直接转成字符串塞进user消息结果模型经常把JSON里的字段当成用户诉求来响应语义完全跑偏。还有一类问题来自系统提示系统提示本该是最优先保留的内容但在字符串拼接模式下它的位置不稳定一旦历史消息长度超过预设系统提示会被挤出有效范围。那时候整个上下文的状态就像一个大行李箱所有东西都往里面塞没有隔层、没有标签、没有任何取舍规则。最后的结果就是该带的东西找不到不该带的塞了一堆。ContextBuilder的引入本质上是给这个行李箱做了隔层、贴了标签并且定义了什么东西必须放、什么东西可以丢。这个比喻一直到我写完整个9.3实践都觉得非常贴切。2. 方案设计先想清楚再写代码2.1 三层处理模型写ContextBuilder之前我给自己定了一个原则先画清楚职责边界再动手写代码。最终我把它拆成了三层。第一层是采集层负责接收所有需要进入上下文的原始信息包括用户消息、系统提示变更、工具调用返回值、内部事件记录。采集层的接口要足够简单调用方不需要关心内部怎么处理只要调用类似add_message、add_tool_result的方法就行。第二层是决策层这是整个Builder的大脑。它负责给每个上下文条目分配优先级、计算Token占用、处理去重和截断、在Token预算不足时决定保留什么丢弃什么。这层逻辑最容易被忽略因为很多人在实现时只想着“把所有内容装进去”完全没考虑模型的上下文窗口是有限的。第三层是输出层负责把决策层筛选出来的上下文条目格式化成模型API要求的messages结构。也许有朋友会觉得直接写死格式不就行了但考虑到不同模型的消息格式有细微差别比如有的要求角色交替有的不接受连续多条同角色消息把输出层独立出来后续换模型时只需要改这一层。分层的另一个好处是测试友好。我可以单独给决策层写各种边界测试而不需要真的去调用模型也可以给输出层写格式校验测试确保生成结果一定满足API约束。9.3实践里我大概写了十来个针对截断和排序的测试用例省下来的调试时间远比写它们的时间多。2.2 数据结构一个ContextItem撑起所有场景分层只是骨架真正让ContextBuilder好用的是它内部的数据结构。我的做法是先定义了一个ContextItem数据类所有进入上下文的信息都统一封装成这个结构。from dataclasses import dataclass, field dataclass class ContextItem: role: str # system / user / assistant / tool content: str priority: int 50 # 0~100值越大越优先保留 source: str unknown # 标记来源message / tool_result / memory timestamp: float 0.0 # 写入时间用于同优先级排序 optional: bool False # 是否为可丢弃信息 cache_key: str # 用于去重比如同一tool_call的结果有的朋友看到这个结构可能会觉得“priority”是多余的直接按时间顺序不就行了实际情况不是这样。时间顺序只解决了一个维度但上下文保留策略需要多维决策。举个例子某个工具返回了一个很长的查询结果它的时间戳很新但Token开销巨大与此同时用户在两轮之前提出的核心需求也很重要。如果只按时间排序工具结果会一直占据大量空间导致用户需求被截断。有了优先级字段就可以让系统提示和核心约束占最高优先级最近用户消息次之工具结果再次之历史对话按时间衰减。cache_key这个字段是我后来加上的故意留在这里。9.3中我们需要频繁处理工具调用的结果如果不做去重同一个工具调用产生的上下文条目可能被多次加入导致模型看到重复信息。cache_key就是用来解决这个问题的。2.3 优先级体系和Token预算的分配原则优先级体系我设计得比较直接0到100数值越大越重要。系统提示固定为100这是整个对话的行为基线优先级最高任何情况下都尽量保留最近一轮用户消息设为90因为模型必须知道用户刚才问了什么工具调用结果设为80工具结果需要紧跟产生它的消息历史消息的优先级随时间衰减初始为70每往前一轮减5最低降到30可选的背景信息设为20是第一个被丢弃的对象。这些数值不是拍脑袋定的。我在9.3实践初期试过“只保留系统提示最近3轮消息”这种简单策略效果很不稳定。后来改成了优先级体系再用一个总Token预算来约束效果明显变好。Token预算本身也是经验值。我一开始把构建目标定成“塞满整个上下文窗口”结果模型输出经常因为预留空间不足而中断。后来改成“只使用窗口的70%”剩下的30%留给模型输出。这里涉及到模型API的一个常识你传入的Prompt Token数和模型生成的Output Token数加起来不能超过上下文窗口上限。如果你把构建器喂满了输入模型输出时就会出问题。70%这个值是从多次实操中试出来的稳妥区间如果模型输出的文本经常很长我会把比例往下调到60%。3. 核心实现与接入过程3.1 基础骨架Builder的增删查改数据结构定完之后Builder本身的实现就水到渠成了。我习惯先把增删查改这类基础能力写扎实再往上叠加组装策略。class ContextBuilder: def __init__(self, max_tokens4096, system_promptNone): self.max_tokens max_tokens self.system_prompt system_prompt self._items: list[ContextItem] [] self._dirty False self._cache None def add_message(self, role: str, content: str, priority: int 50, source: str message, optional: bool False): if not content or not content.strip(): return item ContextItem( rolerole, contentcontent.strip(), prioritypriority, sourcesource, timestamptime.time(), optionaloptional, ) self._items.append(item) self._dirty True def add_user_message(self, content: str): self.add_message(user, content, priority90, sourceuser) def add_assistant_message(self, content: str): self.add_message(assistant, content, priority85, sourceassistant) def add_system_prompt(self, content: str): # 系统提示每次更新都替换而不是追加 self._items [it for it in self._items if not (it.role system and it.priority 100)] self.add_message(system, content, priority100, sourcesystem_prompt) def add_tool_result(self, tool_name: str, result: str, cache_key: str ): content, priority self._format_tool_result(tool_name, result) item ContextItem( roletool, contentcontent, prioritypriority, sourcetool_result, timestamptime.time(), cache_keycache_key, ) # 如果cache_key相同先移除旧条目再写入新条目 if cache_key: self._items [it for it in self._items if it.cache_key ! cache_key] self._items.append(item) self._dirty True def clear(self): self._items.clear() self._dirty True_dirty这个标志是故意保留的。它表示“上一次组装的结果已经失效下次调用build时需要重新计算”。为什么需要它因为build过程涉及排序、去重、截断、格式化是一个相对耗时的操作。如果一条新消息都没有没必要每次调用都重算。很多初版实现最容易漏掉这个细节结果就是高并发场景下反复做无谓的组装白白增加延迟。工具结果的格式化单独拆了个方法原因是这里有一个9.3里反复出现的坑模型希望看到的是“结构化、扁平、可追溯”的工具结果而不是一堆层级很深的原生JSON。def _format_tool_result(self, tool_name: str, result: str) - tuple[str, int]: # 这里对长结果做摘要保留关键字段 if len(result) 800: summary result[:400] \n...(此处省略已按需截断) else: summary result content f[工具 {tool_name} 执行结果]\n{summary} return content, 80真实项目里我不会截到400字就停而是会根据当前剩余预算动态决定。这个版本先固定一个上限后面在截断层再做精细控制。3.2 组装与截断怎么保证输出永远合法Builder的核心方法是build()。它的职责是从_items里筛选出最终要进入模型上下文的条目把它们排序、截断、格式化成messages数组。def build(self) - list[dict]: if not self._dirty and self._cache is not None: return self._cache items sorted(self._items, keylambda x: (x.priority, x.timestamp), reverseTrue) budget self.max_tokens messages [] # 系统提示总是第一个且不进截断队列 if self.system_prompt: sys_cost estimate_tokens(self.system_prompt) budget - sys_cost messages.append({role: system, content: self.system_prompt}) for idx, item in enumerate(items): if item.role system and item.priority 100: continue # 避免重复系统提示 cost estimate_tokens(item.content) if cost 0: continue if cost budget: messages.append({role: item.role, content: item.content}) budget - cost else: # 预算不足高优先级消息尝试截断保留低优先级直接丢弃 if item.priority 80: cropped truncate_to_budget(item.content, budget) if cropped: messages.append({role: item.role, content: cropped}) budget 0 # 预算耗尽跳出循环 break self._cache self._normalize_roles(messages) self._dirty False return self._cache这里有几个细节容易出错。第一是排序按priority降序同优先级按timestamp升序——也就是同等重要的消息先来的排在前面这能尽量维持对话的自然顺序。第二是系统提示单独处理它不进_items队列避免被普通逻辑误伤。第三是截断只对优先级高于80的条目生效普通历史消息在预算不足时直接丢弃不会浪费时间做碎片化截断。Token估算函数我写得很简单基于字符数近似def estimate_tokens(text: str) - int: if not text: return 0 chinese_chars sum(1 for c in text if \u4e00 c \u9fff) other_chars len(text) - chinese_chars return max(1, int(chinese_chars * 1.2 other_chars / 3.8))这个估算公式对中英文混合内容都比较友好。正式项目里如果有条件可以用模型自带的分词器来算会更精确但作为实践项目的默认实现这个估算已经足够稳定。我特意在注释里标明了“估算”两个字避免有人把它当成精确Token统计。_normalize_roles是为了处理角色连续重复的问题。有些模型API不允许出现连续两条user也不允许tool后面没有user或assistant这个函数可以把相邻的相同角色合并起来避免API报错。3.3 接入Hello-Agents对话循环ContextBuilder真正发挥作用是在接入对话循环之后。9.3这一节的完整流程大概是用户发消息Agent判断是否需要工具如果需要就执行工具并把结果写回上下文然后继续让模型决策直到模型给出最终回复。async def handle_turn(user_text: str, session_id: str): builder session_manager.get(session_id) await builder.add_user_message(user_text) for step in range(5): # 最大工具调用轮数防止死循环 messages builder.build() response await call_llm(messages) if not response.tool_calls: builder.add_assistant_message(response.content) return response.content builder.add_assistant_message( f需要调用工具: {response.tool_calls} ) for call in response.tool_calls: result dispatch_tool(call.name, call.arguments) builder.add_tool_result( tool_namecall.name, resultresult, cache_keyf{call.id}, )这个循环里大家最需要关注的是build()不是每推送一条消息就调用一次而是在每次调用模型之前才调用。因为add_*方法只负责写入_items并标记_dirty真正把它们变成模型输入的是build()。这样一个写入一个读取职责非常清晰。我在9.3实践中还加了一个最大工具调用轮数的限制max_steps默认5步。别小看这个限制没有它某个工具因为异常返回了一个触发再次调用的结果代码就会陷入无限循环。这类问题在实际运行中出现概率非常高属于必须提前防护的边界条件。4. 效果验证与参数调优4.1 我在测试中关注的三类场景写完初版Builder后我没有急着把它接进完整Agent流程而是先跑了三类针对性测试。第一类是多轮记忆测试。我设计了一个场景第一轮让模型记住一个偏好设置比如“在所有回复中不主动使用感叹号”到第七轮再返回验证它是否还记得。测试结果表明加了优先级体系后系统提示始终保留在上下文里这类核心约束不会因为历史消息变长而被挤掉。而之前我用简单的“取尾部N条”策略时这个用例会在第四轮开始失败。第二类是工具调用连续性测试。场景是让Agent先查询某仓库的两个版本号然后根据这两个版本号做对比。如果工具结果的优先级太低或者工具结果排在用户问题前面模型经常会忽略后一个工具结果导致对比结论缺失。把工具结果优先级设到80并紧跟在最近的assistant消息之后这个用例的稳定性提升非常明显。第三类是并发会话隔离测试。我同时开了三个会话每个会话的用户身份不一样。如果Builder被设计成全局单例三个会话的上下文会互相污染模型会把A会话的信息当成B会话的输入。这个测试直接逼我把Builder从全局对象改成按会话管理每个会话实例持有一个独立的ContextBuilder。如果你的项目也会被多用户同时访问这部分一定要提前考虑。4.2 Token预算到底该怎么分9.3实践里我把Token预算分配做成了一张内部参照表每种消息类型大概占总输入的多少比例写得很清楚。内容类型预算占比说明系统提示8%~12%行为约束和任务说明必须完整保留最近一轮对话20%~25%用户当前意图、模型最近的回应工具调用上下文25%~35%工具结果和围绕它的分析历史消息摘要15%~25%用摘要代替完整的早期对话模型输出预留30%这部分不计入输入但必须预留上下文窗口这个比例不是一成不变的。如果项目里工具调用很少工具结果的占比可以分给历史消息如果系统提示特别长比如你写了很长的few-shot示例系统提示的占比自然会超过12%。关键是要有预算意识不要等上下文爆了才去想怎么优化。我在实现中还引入了“历史摘要”这个机制当历史消息条数超过一定阈值或者累计Token数超过预算的某个比例时Builder会把部分早期消息交给一个摘要函数用一段概要文本替代完整历史。摘要本身也作为一个ContextItem写入优先级设为70。这一步对保持长期记忆非常有效代价是需要额外调用一次模型来生成摘要时效要求极高的场景需要慎重使用。4.3 一组可以直接抄走的经验参数经过好几轮调试我最终固定下来一组参数这里分享出来供大家参考。不同模型上数字可能略有浮动但整体思路是通用的。参数项推荐值我的使用备注max_tokens0.7 × 模型窗口留出足够空间给模型输出系统提示优先级100永远最后被截断最近用户消息优先级90必须保证当前意图可见工具结果优先级80太高会挤占历史太低会被遗忘历史消息初始优先级70每往前一轮减5最低30截断最低优先级阈值80低于80一律丢弃而非截断工具结果截断上限400字符长结果必须精简化最大工具轮数5防死循环的保险丝这里需要特别说明的是“截断最低优先级阈值”这个参数。之前我天真的以为所有超预算的消息都可以截断保留一部分结果发现大量截断后的碎片消息堆在一起模型理解效果反而更差因为信息都是断的。后来改成“低于80直接丢弃”效果反而好了。保留完整的高优先级消息丢掉不完整的低优先级消息比把所有消息都截成半截强得多。5. 常见问题与排查技巧实录5.1 高频问题速查表在9.3实践后期我把遇到的问题整理成了一个速查表。这些问题我都真实遇到过并且逐一验证过解决方案。现象根本原因我的处理方法模型不记得早期关键信息早期消息被尾部截断策略丢弃引入优先级体系核心信息设高优先级模型把工具结果当成用户指令工具结果没做结构化包装统一用“工具执行结果”模板包裹再写入同一工具结果反复出现同一tool_call被多次写入用cache_key做去重写入前先查重并发会话串话Builder被当成全局单例按session_id隔离每个会话独立实例上下文超限导致API报错没有控制输入Token预算max_tokens设为窗口的70%并做截断build()两次结果不一致组装逻辑依赖外部可变状态保证build是纯计算过程不依赖调用顺序角色出现连续user或tool后直接user未检查消息角色交替build后执行_normalize_roles合并相邻角色模型陷入工具调用死循环某个工具结果触发再次调用增加max_steps限制超过5次强制终止这张表的价值在于大部分问题都不是模型能力问题而是上下文管理逻辑有漏洞。遇到类似现象先检查Builder的写入和组装流程通常比换模型参数更有效。5.2 几个容易踩且不容易发现的坑第一个坑是build()缓存失效时机。我初版把_dirty标志写反了在add_*时没有置为True而是每次build时无条件重算。高并发测试时同一会话连续两次调用build()结果却一样。后来我在每个add_*方法末尾都统一先判断“内容是否真的变化”只有变化才触发重新组装。第二个坑是工具结果截断位置。我一开始用“保留尾部”的策略处理工具结果结果发现模型拿到的总是查询结果的最后一段关键字段经常丢失。后来改成“保留头部结构化摘要”因为多数工具返回的数据里字段名的重要性远大于后面的值。对不同来源的数据做截断时真的要具体情况具体分析不能一刀切。第三个坑是模型输出预留空间。有段时间模型输出频繁被截断我一度以为是模型参数问题排查了很久才发现是max_tokens设置得太满输入几乎占满整个上下文窗口模型没有足够空间生成回复。这是我这轮实践里印象最深的一个教训有些问题看着像模型问题实际是上下文管理问题。第四个坑是摘要调用的递归风险。我给Builder加历史摘要功能之后本来是为了压缩历史结果摘要函数的实现里又调用了call_llm而那个调用又经过同一个Builder等于在Builder内部又触发了一次build。如果不加状态位就会形成递归循环。最终我用一个_summarizing标志位禁止摘要函数内再触发摘要才算彻底解决。结尾的个人体会整个9.3做下来我最深的感受是ContextBuilder这类组件真正难的不是代码量而是决策逻辑的取舍。你用什么样的优先级体系、给每个信息分配多少Token这些选择直接影响模型的最终表现。以前我在一些小项目里也手写过上下文拼接但那时候信息量小问题暴露不出来一旦进入工具调用和多轮对话场景没有一套完整的上下文管理机制整个Agent就会变得很不稳定。如果你也在做类似的Agent项目我建议先花半小时把数据结构、优先级和预算规则想清楚再动手写代码这个前期投入能省下后面大量调试时间。最后再补一个小技巧给Builder的每个写入接口都先加一个简单的空内容过滤看起来不起眼但真的能帮你躲过很多因为空字符串加入上下文而引发的格式问题。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号