ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Hello-Agents ContextBuilder 上下文构建全解析:设计、实现与优化

Hello-Agents ContextBuilder 上下文构建全解析:设计、实现与优化

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_keep=2, tail_keep=6): 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 + tail

3.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, tools=None, retrieval_docs=None, history=None): 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, key=lambda d: d.score, reverse=True) 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(key=lambda 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_budget=8000): 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 messages

4.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(f"Total: {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 index

5.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 这个组件,看起来简单,但真正做好需要对这些细节有深入理解。希望这篇文章能帮你少走一些弯路,把这套东西用得顺手。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进