前阵子被一个线上问题折腾到凌晨三点:客服机器人原本跑得好好的,突然开始把上午聊的订单号当成下午的收货地址来用。排查到最后,问题出在我们压根没认真设计“context-mode”——也就是上下文模式。如果你也做过大模型应用,大概率会对这个词有感知,但它往往是被当成“把历史消息全部拼进去”这么简单的一件事。实际上,context-mode 是决定 LLM 应用是否聪明的隐形分水岭:它回答的是“这次请求,到底该让模型看到哪些上下文、以什么顺序看到、哪些该压缩、哪些该丢弃”。本文就是一次完整的 context-mode 实战复盘,我会从模式划分、实现思路、参数调优到问题排查挨个过一遍,适合所有正在做 AI 助手、AI 客服或智能知识库的开发者参考。
1. context-mode 到底是什么:从一次线上事故说起
先说那次事故。我们的机器人采用最朴素的实现:多轮对话时,把数据库里所有历史消息按时间倒序拼进 prompt,token 不够就从头截掉。上线初期效果尚可,用户每轮只聊三五句,上下文总量不超过 2000 token。但随着真实流量进来,部分用户会连续提问几十轮,历史消息动辄上万 token。强行全量拼接的结果是:模型需要从海量无关信息里“捞”用户的真实意图,注意力被稀释,于是开始把订单号、地址、金额这些实体张冠李戴。
这其实就是 context-mode 缺失的典型症状。所谓上下文模式,核心要做的是下面三件事。
1.1 上下文不是越长越好,而是“该看的都看到,不该看的不出现”
你见过那种做事特别慢的同事吗?他桌面堆着过去一年的所有文件,每找一个文档都要翻半天。全量拼接的 prompt 就是这个状态。大模型的注意力窗口再大,也不是用来做全文检索的;塞进太多无关历史,模型会“迷茫”,会倾向于从最近的 token 里找答案,而不是从真正相关的旧信息里找答案。所以 context-mode 的第一个职责是:做筛选,决定哪些历史有资格进入当前请求的上下文。
筛选的依据通常有三类:时间相关性(近期内容权重高)、主题相关性(和当前问题语义相近的历史更相关)、实体相关性(出现相同订单号、用户名、项目 ID 的历史更相关)。
1.2 压缩是无损保留的近似方案
除了筛选,还要面对一个现实:即使筛选过后,相关历史也可能超长。比如用户聊了 20 轮技术问题,每一轮都与当前问题相关,但总 token 已经破万。这时候你只有两个选择:截断(直接丢掉最老的)或压缩(把旧内容改写为摘要)。截断实现简单但会丢失细节,压缩保留关键信息但可能丢失语气和边缘案例。我实测下来,采用“分层压缩”效果最好:最近的 5 轮保留原始内容,再往前的 20 轮压缩为 300 token 的摘要,更早的只保留实体清单。这个策略本质就是 context-mode 里的“分级上下文管理”。
1.3 模式不是一种,而是一族策略
context-mode 的第三层含义是:不同请求类型应该使用不同的上下文组织策略。用户在闲聊“今天天气如何”时,你不需要把知识库里的产品手册全部塞进上下文;用户在问“我们合同里关于违约金的条款是什么”时,最相关的上下文是合同原文片段,而不是前 10 轮寒暄。把这两种请求用同一种 prompt 构建逻辑处理,必然有一方效果受损。
顺带说一句,业界一些框架里会看到类似的词,比如 context management、context optimization,但 context-mode 更强调的是“策略分发”这个动作:先识别请求的语境类型,再选择对应的上下文组装策略。它处在 prompt 工程与应用架构之间,是一层经常被忽略的胶水。
1.4 三种基础模式速览
我把日常开发中最常用的三种 context-mode 先用一张表总结出来,便于后续展开时对照。这张表是按照“从零开始设计一套可用的模式体系”的经验整理的,不涉及特定框架,只讲策略本身。
| 模式 | 适用场景 | 上下文组成 | 典型 token 预算 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 会话模式 chat | 自由闲聊、多轮问答 | 最近 N 轮原文 + 更早内容的摘要 | 2000-4000 | 交互自然,延续性强 | 长会话仍会遗忘早期细节 |
| 检索模式 retrieval | 知识库问答、文档问答 | 系统指令 + 检索命中片段 + 当前问题 | 1500-3000 | 事实准确,可追溯答案来源 | 依赖检索质量,片段不完整时回答生硬 |
| 工具模式 tool | 调用 API、查询数据库、执行操作 | 系统指令 + 工具定义 + 必要历史 + 当前输入 | 2000-4000 | 结构化输出稳定,适合 agent | 上下文组织复杂,需要额外的结果回填逻辑 |
实际项目中,这三种模式会组合使用。比如一个智能助理可能先用检索模式定位答案,再切换到会话模式延续对话。组合的复杂度比单一模式高一个量级,这也是为什么必须在一开始就把 context-mode 作为一个独立模块设计,而不是写一堆 if-else 硬塞在主流程里。
2. 先定场景再选模式:一套可落地的模式划分与选择策略
我见过不少项目在“模式划分”这一步就走错了路。有些团队把模式分得特别细,什么“售前模式”“售后模式”“技术问答模式”“闲聊模式”“投诉模式”,结果每个模式的 prompt 都要单独维护,出问题时改一处要牵动所有逻辑。我的原则是:模式划分要基于“上下文组织方式的不同”,而不是基于“业务语义的不同”。业务语义可以千变万化,但上下文组织方式就那么几种。
2.1 从业务场景倒推模式需求
假设你做一个电商客服助手,面对的典型请求有这几类:
- 用户问“我昨天买的手机什么时候发货”,这叫订单查询,需要从订单系统拉数据,属于工具模式。
- 用户问“你们 7 天无理由退货包含哪些条件”,这叫政策问答,需要从售后知识库检索,属于检索模式。
- 用户说“好的谢谢”,这叫闲聊/寒暄,直接基于最近几轮对话回应即可,属于会话模式。
如果你把这三类请求全部放进同一个“客服模式”里,意味着每次请求都要把订单数据检索器、售后知识库、历史对话全部准备一遍。结果是 token 浪费严重,模型还会被不相关的信息干扰。正确的做法是先识别请求意图,再分发到不同 context-mode。具体识别手段可以是让模型做意图分类(加一个前置 LLM 调用),也可以用规则(关键词、槽位命中)。我的经验是:能用规则就用规则,规则搞不定的才上模型分类,因为前置分类本身也有延迟和成本。
2.2 四类模式的实际职责边界
我最终在一个项目落地时,把模式收敛为四类。
会话模式(chat):只管对话延续。上下文 = 系统设定 + 最近 N 轮原始对话 + 更早对话摘要。它不做检索、不接外部工具,只负责“聊天”。
检索模式(retrieval):只管从知识库找答案。上下文 = 系统设定 + 检索到的 Top-K 片段 + 用户当前问题。历史对话不是主角,只保留能辅助理解当前问题的最小必要信息,通常是最近 1-2 轮。
工具模式(tool):只管结构化操作。上下文 = 系统设定 + 工具的参数定义 + 当前请求解析结果 + 必要的实体信息。它强调“把话说清楚”,让模型输出一个结构化指令,而不是自然语言回复。
混合模式(hybrid):这是前三种的指挥官。当一次请求同时需要检索和对话延续时,混合模式负责统筹:先判断需要哪些素材,再按优先级组装。比如用户说“那这个政策能用在昨天买的手机吗”,这既需要政策知识(检索),又需要理解“昨天买的手机”这个指代(历史对话),还必须把订单信息拉出来匹配(工具)。混合模式会同时启用三个数据源,并按权重排列:当前问题 > 必要历史 > 检索片段 > 工具结果。
2.3 模式自动选择的工程实现
模式选择这一层,我的做法是维护一个“模式路由表”。每次请求进来,先跑一遍路由逻辑,输出一个模式标识。路由逻辑大概长这样:
- 如果有工具调用信号(比如用户明确要求查单、下单、改地址),直接进入工具模式。工具信号识别用规则就够:出现“查一下”“帮我改”“取消订单”这类动词短语,就命中。
- 如果没有工具信号,就判断是否需要外部知识。做法是让模型在极短的 prompt 里做一次三分类(闲聊、知识问答、工具操作),并把置信度分数返回。分数高于 0.7 才用分类结果,否则默认走会话模式。
- 如果用户在当前轮里使用了“它”“那”“这个”这类指代词,说明高度依赖历史对话,优先使用会话或混合模式,且必须带上最近几轮原文,而不是摘要。
这套逻辑谈不上高深,但胜在稳定。调试的时候最怕的是模式频繁切换:用户刚问完订单(工具模式),下一句问“那能改地址吗”,如果你把这两句拆成两个请求、每个请求都重新路由,模型很难理解“那”指代的是刚才那笔订单。我的解决办法是:在路由结果里增加一个“上下文延续标记”,如果相邻两次请求时间间隔小于 30 秒且属于同一用户,下一次路由自动沿用上一次的模式,不做重新分类。这个小机制大大减少了指代不清的问题。
2.4 各模式的 token 预算分配示例
模式选定之后,紧接着要考虑 token 预算。LLM 有总窗口限制,比如 8K 或 32K 的模型,你不能把所有素材全塞进去。我通常的做法是给每种模式设定一个“三层预算”:总预算的 60% 留给必要素材,20% 留给备用素材,20% 作为安全余量。安全余量必须留,因为模型输出也需要 token,如果 prompt 已经占据窗口的 95%,模型只能输出很短的内容,甚至会截断。
举一个检索模式的具体分配例子:总窗口 8000 token,系统指令占 1500,检索片段最多占 4000,当前问题加最近一轮历史占 1000,安全余量 1500。如果检索到的 Top-5 片段总共 6000 token,超了 2000,就需要按相关性分数裁剪,从第 5 名开始丢弃,直到总 token 回到 4000 以内。这里的“按分数裁剪”听着简单,实际还要注意:片段不要从中间截断,要整段丢弃或者做二次摘要。从中间切开一个段落送进去,模型会理解出错误信息。
3. 工程实现:context-mode 核心代码与参数调优
讲完策略,下面进入工程实现。我以 Python 为例,展示一个轻量级的 context-mode 模块设计思路。这套设计不依赖任何特定框架,你可以直接搬到自己的代码库里。
3.1 基础抽象类设计
我的做法是定义一个 BaseContextMode 抽象类,所有模式继承并实现 build_prompt 方法。这个方法接收一个“请求上下文对象”,输出一个组装好的 prompt 字符串和 token 统计信息。
from abc import ABC, abstractmethod from dataclasses import dataclass from typing import List, Optional @dataclass class Turn: role: str # user / assistant content: str timestamp: float @dataclass class RequestContext: user_id: str current_input: str history_turns: List[Turn] retrieved_chunks: Optional[List[str]] = None tool_result: Optional[str] = None mode: str = "chat" class BaseContextMode(ABC): def __init__(self, budget: int, safety_margin: float = 0.2): self.budget = budget self.usable = int(budget * (1 - safety_margin)) @abstractmethod def build_prompt(self, ctx: RequestContext) -> str: pass def _count_tokens(self, text: str) -> int: # 实际项目中用 tiktoken 或 transformers 的分词器 # 这里是简化估算:中文按 1 字 1 token,英文按 3 字母 1 token return max(1, len(text))这里有个关键点:Budget 与 safety_margin 的组合。我一开始没有单独划 safety_margin,结果模型在长对话中频繁出现“响应截断”,因为 prompt 占满了窗口,留给输出的空间不够。后来统一加上 15%-20% 的安全余量,问题基本消失。这个余量不是固定值,如果模型需要输出较长的结构化 JSON,建议留 25%;如果只是短句回复,10% 就够。
3.2 会话模式的滑动窗口实现
会话模式最核心的是“最近 N 轮保留原文,更早内容做摘要”。先看代码:
class ChatContextMode(BaseContextMode): def __init__(self, budget: int = 4000, recent_turns: int = 5): super().__init__(budget) self.recent_turns = recent_turns def build_prompt(self, ctx: RequestContext) -> str: system_prompt = "你是一个友善的助手。请结合对话上下文,自然地回应用户的最后一句话。" # 拆分历史:最近的 N 轮保留原文,更早的进入摘要区 recent = ctx.history_turns[-self.recent_turns:] older = ctx.history_turns[:-self.recent_turns] # 构造片段列表,方便后续按 token 预算裁剪 segments = [] segments.append(("system", system_prompt)) segments.append(("current", f"用户当前输入:{ctx.current_input}")) # 更早的历史压缩为摘要 if older: summary = self._summarize_older(older) if summary: segments.append(("summary", f"更早对话摘要:{summary}")) for turn in recent: role_name = "用户" if turn.role == "user" else "助手" segments.append(("history", f"{role_name}:{turn.content}")) return self._fit_to_budget(segments) def _summarize_older(self, older_turns: List[Turn]) -> str: # 实际项目中,这里会调用一次 LLM 做摘要 # 摘要的输入是 older_turns 的全文,输出是一个 200-300 token 的概述 # 为了演示,这里直接取首尾关键内容 if len(older_turns) <= 2: return "" first = older_turns[0].content[:50] last = older_turns[-1].content[:50] return f"用户曾提及:{first}... 最近一次提及:{last}" def _fit_to_budget(self, segments) -> str: # 从最重要的段开始保留,直到预算耗尽 priority = {"system": 1, "current": 1, "history": 3, "summary": 5} ordered = sorted(segments, key=lambda x: priority[x[0]]) result = [] used = 0 for seg_type, text in ordered: token_count = self._count_tokens(text) if used + token_count > self.usable: if seg_type == "summary": continue # 摘要可丢 elif seg_type == "history": # 历史段超出预算时,只截取后半段 remain = self.usable - used text = text[-remain:] if text: result.append(text) used = self.usable break else: break result.append(text) used += token_count return "\n\n".join(result)有个容易被忽略的细节:在 _fit_to_budget 里,历史段的裁剪方向。如果我优先保留“最开头的历史”,那模型反而看到一段没有下文的对话,无法形成连贯记忆。所以历史超预算时应该保留靠后的部分,也就是离当前问题更近的内容。这个方向上错了,效果会非常差,我在踩过一次坑之后才意识到。
摘要的生成我这里用了简化逻辑,真实项目中摘要本身也是一个 LLM 调用。这里要提醒一点:摘要的输入不应该太长,如果 older 部分已经超过 6000 token,需要先做分段摘要,再把各段摘要合并成一份总摘要。分段摘要的关键是每段要有标题或者角色标记,否则合并时会丢失对话轮次感。
3.3 检索模式的拼接与裁剪
检索模式的实现重点有两个:一是检索片段怎么拼进 prompt,二是检索结果怎么裁剪到预算内。
class RetrievalContextMode(BaseContextMode): def __init__(self, budget: int = 3000, top_k: int = 5): super().__init__(budget) self.top_k = top_k def build_prompt(self, ctx: RequestContext) -> str: system_prompt = ( "你是一个知识库问答助手。请根据提供的参考资料回答用户问题。" "若资料中找不到答案,请明确说'资料中未找到相关信息'。" "回答时尽量引用资料原文,不要编造事实。" ) segments = [] segments.append(("system", system_prompt)) segments.append(("current", f"用户问题:{ctx.current_input}")) # 历史只保留最近一轮,避免干扰 if ctx.history_turns: last = ctx.history_turns[-1].content segments.append(("context", f"对话背景:{last[:200]}")) # 检索片段按相关性分数排序 chunks = ctx.retrieved_chunks or [] if chunks: segment_text = "\n".join( f"[参考片段{i + 1}] {chunk}" for i, chunk in enumerate(chunks) ) segments.append(("reference", segment_text)) return self._fit_to_budget(segments) def _fit_to_budget(self, segments) -> str: # 与 ChatContextMode 类似,但 reference 段的裁剪策略不同: # 优先丢弃编号靠后的片段,且保持片段完整性 reference_text = "" for seg_type, text in segments: if seg_type == "reference": reference_text = text # 如果 reference 超预算,需要从后面的片段开始丢弃 if self._count_tokens(reference_text) + self._count_tokens(" ".join(s[1] for s in segments if s[0] != "reference")) > self.usable: # 简化处理:按整段 chunks 重新拼接 chunks = ctx.retrieved_chunks or [] # 注意:这里需要访问 ctx,实际可存到构造函数 new_chunks = [] current_len = 0 for chunk in chunks: chunk_len = self._count_tokens(chunk) if self._count_tokens("\n".join(new_chunks)) + chunk_len > 4000: continue new_chunks.append(chunk) # 重新构造 segments pass return "\n\n".join(f"{s[1]}" for s in segments)我简化了代码,但你应该能看出关键思想:retrieval 模式裁剪时,宁可丢弃整个片段,也不要在片段中间截断。因为检索片段通常是从文档中切出的段落,它有内在的语义边界。从中间截断会产生一个不完整的论断,模型如果用这个片段作答,会一本正经地胡说八道。
此外,检索模式在 prompt 里加入“若资料中未找到相关信息”这句约束非常重要。没有这句时,模型倾向于强行从资料里“编”出一个答案;加了这句之后,模型会更诚实地承认“不知道”。这个看似微小的 prompt 差异,直接影响知识问答的幻觉率。
3.4 工具模式的 JSON 输出稳定化
工具模式的最终目的是让模型输出一段结构化指令,而不是自然语言。这里最大的难点是“输出格式不稳定”,模型偶尔会多解释几句,偶尔会漏掉必填字段。我的经验是在 prompt 里同时做三件事:给出一份完整的 JSON Schema 示例,要求只能输出 JSON,不输出任何额外文字;提供历史失败样例;解析阶段做容错。
class ToolContextMode(BaseContextMode): def build_prompt(self, ctx: RequestContext) -> str: schema = """ { "tool_name": "string", "params": { "order_id": "string", "operation": "modify_address", "new_address": "string" } } """ examples = """ 用户说:帮我改成新地址 北京市朝阳区某某路1号 输出:{"tool_name": "update_order", "params": {"order_id": "...", "operation": "modify_address", "new_address": "北京市朝阳区某某路1号"}} """ system_prompt = ( "你是一个工具调用引擎。根据用户请求生成一个 JSON 指令。" "只能输出 JSON,不要输出任何解释性文字。\n" f"输出格式要求:{schema}\n" f"示例:{examples}" ) history_prompt = "" if ctx.history_turns: recent = ctx.history_turns[-2:] history_prompt = "\n".join(f"{t.role}: {t.content}" for t in recent) return ( f"{system_prompt}\n\n" f"最近对话:{history_prompt}\n" f"当前用户请求:{ctx.current_input}\n" f"请输出 JSON 指令:" )这里透露一个从实战中积累的小技巧:如果模型总是漏掉必填字段,就在 JSON Schema 示例里把必填字段用占位符写一遍,并在示例中展示两次。用户说“帮我改地址”,如果示例里只有一次“postCode”,模型可能漏掉;如果示例里展示了“postCode: xxx”同时字段描述里又有“postCode 为必填”,模型的漏填率会显著下降。这背后的直觉是:示例的权重远高于字段描述的权重,模型是在“模仿”而不是“理解”。另外,解析 JSON 时一定不要直接 json.loads 然后失败就抛异常。模型偶尔会在 JSON 前后多出几个反引号或者“好的,这是你要的 JSON:”这类前缀。我通常先尝试直接解析,失败后用正则把最内层的 JSON 块提取出来,再尝试解析一次;仍然失败才走“重新调用模型”的补救路径。这个容错逻辑让工具模式的稳定率从 85% 提升到了 97% 左右。
4. 常见问题与排查技巧实录
这一节整理的是我在多个项目里遇到的典型问题。这些坑要么不踩不知道,要么踩完才后知后觉,但每一个都非常有代表性。
4.1 prompt 过长导致输出截断
现象:对话轮次一多,模型要么不回复,要么回复到一半就断掉。最坑的是这种截断经常发生在 JSON 输出场景,导致 parse 直接失败。
原因分析:prompt 占了窗口的 95% 以上,模型可生成的 token 数量太少。另一个隐藏原因是部分模型有“最大输出 token”限制,即使窗口还剩空间,单次输出仍有上限。
排查与解决:
- 在 context 日志里记录 prompt 的 token 统计和输出 token 统计,对比两者比例。
- 如果发现 prompt 超过总窗口的 75%,就应该考虑裁剪或者压缩。
- 设置输出 token 上限(max_tokens),而不是让它默认跑到最大值。实际项目中我习惯把 max_tokens 设置为预算余量的 70%,留出缓冲。
- 一个容易忽略的点:部分模型(特别是 MoE 类模型)对 prompt 过长时,输出的 length 会不稳定。我测试下来,prompt 控制在窗口的 60% 以内时,输出截断率下降非常明显。
4.2 模式误切换:用户觉得机器人在“失忆”
现象:用户问完“我订单号是 12345”,机器人在下一句话里把订单号忘了,重新问“请问您的订单号是多少”。这通常不是模型的问题,而是第二次请求走了检索模式,历史对话没有带进去,模型压根看不到订单号。
原因分析:路由逻辑每次都做独立判断,没有考虑相邻请求的上下文延续性。
排查与解决:
- 为每个用户会话维护一个“当前模式”状态,如果两次请求间隔小于 30 秒,沿用上一个模式。
- 检查检索模式是否只保留了最近 1-2 轮历史。如果需要解析指代(“那”“它”“这个”),要临时扩展为最近 5 轮,或者把上一轮的实体提取出来注入当前 prompt。
- 在日志里记录每次请求的模式标识,发现问题时先看模式是否频繁跳变。我见过一个项目模式跳变率高达 40%,几乎每次请求都在换模式,效果自然差。
4.3 检索召回不足,模型开始“编”
现象:知识库问答中,模型经常回答得有理有据,但内容完全不在资料里。这是典型的幻觉问题,而幻觉的根源往往是检索召回不足:真正有用的片段没有进 Top-K 列表,模型找不到答案就只能自己编。
原因分析:检索的 query 是用户原话,原话中存在口语化表达、代词、噪音词,降低了向量检索的命中率。
排查与解决:
- 不要直接用用户原话检索,先做一个“检索 query 改写”的 LLM 调用,把口语化成书面语,把代词替换为具体实体。
- 提高 Top-K 的值,让更多候选片段进入 context。检索模式是“宁可多召回,靠裁剪处理”,而不是“少召回省 token”。
- 检索结果增加重排(rerank)逻辑:向量召回的 Top-50 再送进一个小的交叉注意力模型排序,取 Top-5。实测重排后命中率能提升 20% 以上。
- 在 prompt 里增加“若资料中未找到相关信息,请回答‘资料中未找到’”的约束,至少能避免一部分强行编造。
4.4 摘要丢失关键实体
现象:会话模式中,早于最近 5 轮的内容被摘要化,但摘要里恰好丢了订单号、日期等重要实体,用户再问起时模型拿不到。这类问题很隐蔽,因为模型会用一个“似乎是”的模糊表述回答你,不仔细看发现不了。
原因分析:摘要调用时没有做“实体保护”。LLM 做摘要时更关注语义概括,而不是细节保真。
排查与解决:
- 摘要生成前,先用正则或 NER 抽出关键实体(订单号、日期、金额、编号),把这些实体列表单独拼接到摘要末尾。我称之为“实体保险箱”。
- 摘要的 prompt 里明确要求“必须保留所有数字、ID、日期、地址信息”。
- 不要只做一次全局摘要,而是做“滚动摘要”:每 5 轮生成一段新摘要,下次再把新摘要和全文一起压缩。滚动摘要比一次性长摘要效果更好,因为模型每次只需压缩一小段,注意力更集中。
4.5 调试 context-mode 最关键的手段:结构化的 context 日志
最后分享一个排查所有上下文问题的通用手段:把每次请求的 context 构建过程完整记录下来。记录不是记录回归测试里的 prompt 全貌就够了,而是要记录“每个段落的来源和裁剪情况”。我通常在 context 日志里输出这样一个 JSON:
{ "request_id": "xxx", "mode": "retrieval", "user_id": "123", "budget_total": 8000, "budget_used": 5230, "segments": [ {"type": "system", "source": "global_config", "tokens": 1500}, {"type": "reference", "source": "top1_rank0.87", "tokens": 2200}, {"type": "reference", "source": "top3_rank0.64", "tokens": 180}, {"type": "current", "source": "user_input", "tokens": 120} ], "dropped": ["top5_rank0.23", "older_summary_segment"] }有了这份日志,你可以直接从“source: top1_rank0.87”里看出这次模型依赖了哪段资料,从 “dropped” 里看出哪些内容被丢弃了。沿着这个日志逐条排查,大部分 context 问题的根因都能定位到具体环节,而不是让模型背锅。
5. 实测效果与调优心得
最后聊一点带主观色彩的实测感受。在一次智能客服系统的升级中,我把项目从“全量拼接历史”迁移到上面的多模式 context-mode 架构,并且记录了切换前后的对比数据。做对比时我特意选了同一批真实用户请求,用相同模型,只是 context 构建方式不同。这里我不放具体数值,因为不同业务差异太大,但趋势非常明显:长会话场景的指代理解错误大幅减少,检索问答的答案引用准确性明显提高,prompt token 成本反而下降了不少——原因是全量拼接时候有大量无关历史被白白喂给模型。
我印象最深的一个点:模式路由带来的收益。之前所有请求都走同一条 prompt 链路,模型偶尔会用知识库资料去回答案例的闲聊,比如用户说“哈哈那就这样吧”,模型居然回“根据我们的售后政策,很高兴为您服务”。切换模式路由后,这类滑稽回答几乎消失。这说明对 LLM 应用来说,很多时候模型表现不佳,根因不在模型本身,而在“你有没有把该给的信息给它、把不该给的噪音拿走”。context-mode 本质上解决的就是这个问题。
还有一个实际经验想分享给你:调试 context-mode 时要有一点耐心,不要指望一次调好。我的工作方式是每次只改一个变量,比如这次只调整 recent_turns 的值,下次只调整摘要的 token 预算,不要同时改多个参数。因为 context 环节相互牵连,同时改两处很难判断效果来自哪一处。改完之后用一组固定的 20 条测试请求做回归,每条都人工打标“好/坏”,再用这个标签对比调参前后的表现。这套笨办法比任何“直觉调优”都靠谱。
另外,设计 context-mode 时考虑一下模型迭代的影响。我遇到过这样的情况:版本 A 模型在各模式里表现都不错,升级到版本 B 后,检索模式的 JSON 输出开始不稳定。原因不是代码变了,而是新模型对 prompt 格式的敏感度不同。所以 context 构建逻辑最好做成可配置的模板,模型升级后可以快速切换模板做对比,而不是把 prompt 硬编码在业务代码里。
最后再分享一个小技巧。如果你刚开始做 context-mode,先别急着把所有模式都实现了。我建议从会话模式和检索模式两个基础模式开始,跑通“模式路由 → prompt 构建 → 响应解析”这条链路,再逐步加入工具模式和混合模式。一次引入过多模式,出了问题都不知道该从哪里查起。先把地基打牢,后面的扩展就是顺势而为。