做LLM应用开发,时间久了你会撞上一个特别拧巴的规律:同一个模型,换个场景、换个会话长度,回答质量就像过山车。很多时候问题并不在模型本身,而在context-mode——上下文模式。这个词是我在做客服机器人重构时自己冒出来的:当时系统把整段历史会话原封不动喂给模型,轮数一多就崩,关键信息被淹没在旧日志里,用户中途改个需求,AI还在按上一版逻辑走。后来我把上下文拆成不同模式,按场景动态切换,效果才算稳住。这篇就把"上下文模式"这套做法拆开讲清楚:它解决什么问题、怎么设计、怎么用代码落地,以及我踩过的那些坑。无论你是做AI产品、写Agent,还是只是用大模型API做点小工具,这套思路都能直接搬。
1. 先搞清楚context-mode到底在解决什么问题
1.1 一次事故:没有模式的上下文,就是一锅粥
去年我接了个客服机器人的优化需求。线上的问题很典型:用户聊到第20轮的时候,模型开始频繁"失忆"。比如用户前面报了订单号,后面问"那这个能退吗",模型不知道"这个"指什么;又比如用户已经说过"不要短信,要电话联系",隔几轮再重复一遍需求,模型反而当新需求处理。
我拉出日志一看,当时的实现很简单粗暴:把所有对话记录按时间顺序拼起来,截取最后的N个字符塞进prompt。看起来好像没问题,但实际效果非常差。原因也直白:上下文中塞了大量闲聊、过时状态、重复信息,真正对当前决策有用的内容反而被挤出窗口。更麻烦的是,有些用户会中途改变出入信息,旧指令和新指令混在一起,模型根本分不清哪个是当前优先级。
那段时间我每天改prompt模板,改来改去效果还是飘。后来我意识到,问题不是"塞多少",而是"怎么塞"。模型需要的是一个结构化的、有优先级的上下文,不是一个平铺的聊天记录。这个思路,就是我说的context-mode。它本质上是给上下文的组装方式定了一套策略:什么时候该带什么、不该带什么、优先级怎么排、超出窗口后先牺牲哪部分。
1.2 上下文窗口不是黑盒子:先补三个基础概念
要理解context-mode,得先明白三个概念:token、上下文窗口、信息密度。
Token很好解释,它是模型计费和处理的最小单位。中文场景下,一个token大致相当于0.5到1个汉字,具体取决于分词器。上下文窗口就是模型一次能"看到"的token总量,常见的有4k、8k、16k、32k甚至更多。但窗口大不等于"全塞进去就好",两个原因:成本、信息密度。
成本不必多说,按token计费,塞得越多每次调用越贵,延迟也越高。信息密度才是更隐蔽的问题:模型对长上下文的注意力不是均匀分布的。业界有个著名的现象叫"lost in the middle",意思是模型对上下文开头和结尾的内容记忆得更牢,中间部分经常被忽略。你把关键订单号放在长长的历史中间,模型大概率就是看不到。这就好比你办公桌上堆满文件,真正要处理的那张回执被埋在第30层下面,老板问的时候你根本翻不到。
所以context-mode的核心目标,不是把窗口填满,而是把高价值信息放在模型最容易注意到的位置,同时控制总token在合理预算内。它跟常见的"截断"不一样:截断是单向删除,而context-mode是按优先级重新组织上下文结构。
1.3 为什么单靠截断撑不住复杂场景
不少同学第一反应是:那我把context窗口用大模型厂商的"自动压缩"功能,或者简单保留最近10轮不就行了?说实话,简单场景能撑住,复杂场景会翻车。
我用一个电商售后场景举例。用户说"订单号20240901的鞋子要退货",接着又聊了15轮物流、优惠券、发票的事,最后问"现在能退了吗"。如果只保留最近10轮,订单号"20240901"和"退货"这个核心意图可能已经被挤出去了,模型只能回答"我无法确认您指的是哪笔订单"。如果全量保留,中间15轮物流、优惠券、发票的细节又把关键信息稀释了。
context-mode要解决的就是这个"关键信息保真"问题。它先把上下文按功能拆成几个block,比如系统指令、长期记忆、检索知识、历史对话、当前用户输入,再给每个block设定优先级和token预算,组装时按规则取舍。这样订单号这种高价值信息会被放在单独的结构化字段或靠前位置,不会被闲聊冲散。同时它还能根据当前会话状态动态切换模式,比如用户刚开始问问题用"短对话模式",聊了几十轮切"长会话模式",问的是文档内容就切"知识增强模式"。
2. 设计context-mode:核心思路与模块拆解
2.1 四种最常见的上下文模式
我实践中用得最多的,是下面四种模式,它们基本覆盖了绝大多数真实业务场景。
短对话模式:适合一次性问答、工具调用、明确指令的场景。上下文只包含system prompt、当前用户输入、极少量最近历史(通常2到4轮)。优点是省token、响应快、不容易被历史带偏。很多API工具类调用都应该用这种模式,不是所有对话都需要完整记忆。
长会话模式:适合多轮对话、有持续性需求的场景。核心是加了一层"会话摘要":把早期对话压缩成一段结构化记忆,而不是全量保留原文。比如用户前面说的个人信息、偏好、确认过的事项,压缩成"用户地址:北京朝阳,联系电话:138xxxx,偏好电话联系,当前正在处理退货申请",这段摘要占据一个固定token预算,后面才挂最近的对话细节。
知识增强模式:适合文档问答、企业知识库这类"模型需要外部资料才能回答"的场景。上下文由system prompt、用户输入、检索召回的top-k条知识切片组成。关键点在于:知识切片要有来源标记,顺序按相关度排列,并且永远放在用户问题之前,这样模型回答时会优先"引用"这批知识。
Agent规划模式:适合需要多步工具调用的复杂任务。上下文不是纯对话,而是一个"任务状态视图":当前目标、已完成动作列表、观察到的中间结果、下一步计划。这个模式的好处是防止Agent做了一半"忘记初衷",把之前步骤的中间结果全量保存起来。
下面这个表格基本能对上不同场景的选型:
| 模式 | 典型场景 | 核心上下文构成 | token开销 |
|---|---|---|---|
| 短对话 | 单轮问答、指令执行 | system + user + 最近少量历史 | 低,稳定 |
| 长会话 | 客服、咨询、陪伴 | system + 摘要记忆 + 最近对话 | 中,随轮数微涨 |
| 知识增强 | 文档问答、企业知识库 | system + 检索切片 + user | 中,取决于召回数量 |
| Agent规划 | 多步任务、工具调用 | system + 任务状态 + 中间结果 | 高,但可控 |
2.2 拆解核心模块:ContextBuilder、Selector、SummaryEngine
设计模式只是一半,真正落地需要一个明确的分层架构。我的项目里核心拆成了下面几个模块,每个模块职责单一,出了问题也好排查。
ContextBuilder(上下文组装器):负责把不同类型的block拼成最终prompt。它只做组装和token预算控制,不做业务判断。输入是一组带优先级的ContextBlock,输出是一段格式化的文本。
Selector(模式选择器):负责决定当前该用哪种模式。输入是当前会话状态(轮数、意图、有没有检索结果、是不是多步任务),输出是模式标识。现实中最稳的是规则驱动:根据会话轮数、关键词、状态机字段来切换,而不是让LLM自己判断"你现在该用什么模式",因为那会额外花一次模型调用,还不稳定。
SummaryEngine(摘要引擎):负责长会话下的历史压缩。它把Old History + New Messages合并,重新生成一段摘要,确保关键约束不丢。这个模块必须用结构化模板约束,否则摘要就变成泛泛的"用户聊了购物相关话题",什么有效信息都没留下。
BudgetController(预算控制器):负责在整个组装过程中控制token。每个block有优先级和预设的token配额,超预算时低优先级先被裁掉。这一步很像操作系统里的内存淘汰策略。没有它,上面的模块再好看也会在长上下文场景里崩掉。
2.3 模式选择策略:先规则,后模型
模式选择是context-mode里最容易过度设计的地方。我见过不少方案一上来就上"意图分类模型""LLM动态判断当前模式",效果反而不稳定。为什么?因为模式切换这种高频、低延迟的操作,最适合的是确定性规则。
做一个合格的规则选择器,通常只需要几个信号:
- 会话轮数(>12轮切长会话模式)
- 是否触发检索(用户问题包含产品名、文档关键词,切知识增强模式)
- 是否包含工具调用/任务分解(检测到需要多步操作,切Agent规划模式)
- 会话状态字段(比如电商场景里,如果用户已经进入了售后流程,强制使用长会话+结构化字段)
菜单比模型稳。规则先行,能cover掉80%的情况;剩下拿不准的,再让模型做一个轻量的分类判断,并给足超时兜底。我自己实测下来,这套组合的稳定性远好于全交给模型选型。
3. 实操落地:从零实现一个context-mode管理器
3.1 先定数据结构:可管理的前提是可度量
我会直接给一套Python实现思路,不需要依赖任何重量级框架,纯标准库加一个tiktoken就能跑起来。
第一步是把"一条消息"和"一个上下文块"建模。消息好理解:role、content、token数;上下文块还要加一个priority字段,代表它在预算紧张时的保留优先级。数字越大越重要,越晚被裁。
import tiktoken from dataclasses import dataclass, field from typing import Optional, List @dataclass class Message: role: str # system / user / assistant / tool content: str token_count: int = 0 def count_tokens(self, encoder) -> int: self.token_count = len(encoder.encode(self.content)) return self.token_count @dataclass class ContextBlock: block_type: str # system / summary / retrieved / history / user content: str priority: int # 优先级:越高越不容易被裁 token_count: int = 0 meta: dict = field(default_factory=dict)为什么用dataclass而不是普通dict?因为我们需要在组装、排序、调试时频繁读取属性,dataclass结构清晰、自动生成__repr__、不易写错key,更重要的是后面加字段不容易出错。对于中小型项目,这种轻量建模比引入Pydantic更直接。
token估算直接用openai的tiktoken库。注意:不同模型的分词器不完全一样,如果做多模型适配,需要在encoder上做映射。
def get_encoder(model_name: str = "gpt-4o"): try: return tiktoken.encoding_for_model(model_name) except KeyError: return tiktoken.get_encoding("cl100k_base")3.2 Token预算:没有计量就没有优化
组装上下文之前,一定要先定预算。我的习惯是项目全局设一个MAX_BUDGET,比如8000 token,然后按四个层级分配:
- System指令:10%,一般是800-1000 token
- 会话摘要/记忆:15%,限制在1200以内
- 检索知识/业务数据:25%,最多2000
- 最近对话历史:40%,约3200
- 当前用户输入与输出预留:剩余10%
这个比例不是固定的,要根据业务调整。比如知识问答场景,检索知识占比可以提到40%,历史对话压到20%;Agent规划场景,任务状态占比要额外给足。分配比例的意义在于:给每个block一个大致的心理预期,组装时不会出现"某个部分无限膨胀把其他部分全挤掉"的情况。
实现预算控制器时,重点不是"等比例缩放",而是"按优先级淘汰"。举个例子:假如知识块占2000 token,但系统只剩1500 token,那么知识块只保留相关度最高的部分,而不是整体缩小所有block。因为整体缩小会让systme指令、用户当前问题这些高价值内容也变短,这是最亏的。
3.3 核心组装逻辑:把不同块拼进prompt
组装器的核心是一个优先级排序加预算裁剪的循环。我给出一个可用的简化实现,它足够作为你项目的基础骨架。
class ContextAssembler: def __init__(self, token_budget=8000, model="gpt-4o", reserve_for_output=800): self.token_budget = token_budget self.reserve_for_output = reserve_for_output self.encoder = get_encoder(model) def count(self, text: str) -> int: return len(self.encoder.encode(text)) def assemble(self, blocks: List[ContextBlock], user_text: str) -> str: # 先扣掉输出预留和用户输入,得到可用预算 available = self.token_budget - self.reserve_for_output - self.count(user_text) # 按优先级从高到低排序 sorted_blocks = sorted(blocks, key=lambda b: b.priority, reverse=True) selected: List[ContextBlock] = [] used = 0 for block in sorted_blocks: if used + block.token_count <= available: selected.append(block) used += block.token_count else: # 还剩的token如果大于50,就截断这个block,尽量利用剩余空间 remain = available - used if remain > 50: truncated_text = self._truncate_with_tokens(block.content, remain) selected.append(ContextBlock( block_type=block.block_type, content=truncated_text, priority=block.priority, token_count=remain, meta={**block.meta, "truncated": True} )) break return self._format_prompt(selected, user_text) def _truncate_with_tokens(self, text: str, max_tokens: int) -> str: tokens = self.encoder.encode(text) tokens = tokens[:max_tokens] return self.encoder.decode(tokens)组装后的格式很有讲究。我一般用清晰的分隔标签把每个block包起来,让模型一眼看懂结构。这套模板看起来平平无奇,但对模型遵循指令的效果影响很大:
def _format_prompt(self, blocks: List[ContextBlock], user_text: str) -> str: sections = [] for block in blocks: if block.block_type == "system": sections.append(f"<system>\n{block.content}\n</system>") elif block.block_type == "summary": sections.append(f"<memory>\n{block.content}\n</memory>") elif block.block_type == "retrieved": for item in block.content: sections.append(f"<context source=\"{item['source']}\">\n{item['text']}\n</context>") elif block.block_type == "history": sections.append(f"<conversation>\n{block.content}\n</conversation>") sections.append(f"<user>\n{user_text}\n</user>") return "\n".join(sections) + "\n\n<assistant>"注意最后加了<assistant>,模型会被引导接着这个token继续补全,回复会更自然,不会重复用户的问题。
3.4 模式选择器和完整调用示例
组装器有了,还差一个模式选择器。我用一个简化版展示思路:核心逻辑是读会话状态,返回一个配置,配置决定组装哪些block以及怎么分配预算。
class ContextModeSelector: def __init__(self, message_window=12, retrieval_threshold=0.7): self.message_window = message_window self.retrieval_threshold = retrieval_threshold def select(self, state): # state: dict, 包含 message_count, retrieval_score, is_multi_step_tool_call if state.get("is_multi_step_tool_call"): return "agent" if state.get("retrieval_score", 0) >= self.retrieval_threshold: return "knowledge" if state.get("message_count", 0) > self.message_window: return "long_session" return "short"实际项目里,state来自你的会话管理模块。比如每轮对话后更新message_count、判断当前意图类型、给用户问题做一个向量检索并拿到相似度分数。这些信号都齐了,选择器就非常简单。很多同学纠结"怎么选模式",其实复杂度不在选择器,而在你是否有可靠的state信号。
再给一个完整调用示例:
def chat(state, user_text, retriever=None, memory=None, llm=None): mode = selector.select(state) blocks = [ContextBlock("system", SYSTEM_PROMPT, priority=100)] if mode == "knowledge" and retriever: hits = retriever.retrieve(user_text, top_k=4) if hits: blocks.append(ContextBlock("retrieved", hits, priority=80)) if mode in ("long_session", "agent"): summary_text = memory.get_summary(state["session_id"]) if summary_text: blocks.append(ContextBlock("summary", summary_text, priority=90)) recent_history = state.get_recent_messages(window=8) if recent_history: blocks.append(ContextBlock("history", recent_history, priority=60)) prompt = assembler.assemble(blocks, user_text) response = llm.chat(prompt) return response这里把retriever和memory都做成接口,方便你替换成自己的实现。这个骨架看起来简单,但已经足够支撑一个日均几十万调用的生产级对话系统。
3.5 摘要引擎:别把"总结"做成"信息流失"
长会话模式下,摘要引擎是最容易翻车的。很多团队用一句"对上面对话做总结"就把摘要扔给模型,结果摘要出来是"用户咨询了退货和物流问题"这种废话,订单号、地址、联系方式全没保住。
我的摘要有固定的模板约束,核心字段必须保留:
根据以下对话,生成结构化会话记忆。必须保留: 1. 用户身份与联系方式(如果有) 2. 商品/订单/业务对象标识(订单号、SKU、工单号等) 3. 当前进度与未完成事项 4. 用户的明确偏好或约束(负面约束尤其重要) 5. 最近一次状态变更 不要输出无关评价,只输出结构化要点。生成摘要时还有一个细节:不是把所有老消息重新输入给模型,而是用"上一版摘要 + 新增消息"的增量更新。这样能大幅节省token,也能保持摘要的连续性。每5轮或摘要token超过阈值时触发一次更新。这次更新也纳入同样的context-mode体系:摘要生成请求的system就是那条模板,用户内容就是旧摘要+新增消息,输出就是新摘要。
4. 常见问题与排查技巧实录
4.1 上下文被截断后,系统指令丢了
排查问题第一步是看prompt日志。我遇到过好几次:model回复风格突变、突然说"我不能访问外部工具"之类的话,一查prompt日志,发现system block没进去。原因通常是priority设低了,组装器按优先级排序时把system挤出了预算。
但这里有个陷阱:我的assemble实现是按block优先级排序后从头往预算里塞。如果你把history的priority设得比system高,历史就会先把预算占满,system即使排在前面也会被挤掉。所以system是整个上下文里优先级最高、永远不能被裁的部分。正确做法是组装时单独把system block强制放进结果里,再从剩余预算去裁其他block,而不是统一排序。
4.2 检索内容与用户问题错位,模型被"带偏"
知识增强模式下,最典型的故障是:检索召回的top-k切片里,前两条相关度很高,后两条却是噪声文本。模型有时会抓住噪声里的信息,回答反而偏离了正确知识。
我的解法有三个:
- 召回后必须做rerank,至少在代码里按向量相似度再筛一遍,去掉低于阈值的切片
- 给每条知识加来源标签,让模型知道"引用哪段";同时告诉它"如果检索内容与用户问题无关,请忽略并说明无法回答"
- 控制召回数量,宁可要2条高相关,不要5条里含3条噪声
这个经验很直观:不给模型一堆似是而非的素材,它反而更容易给出准确答案。
4.3 token估算偏差导致请求超限
我在切换模型时吃过亏:原来用gpt-4o算token,换到另一个上下文窗口更小的模型后,同样的prompt直接请求超限。问题在于不同模型的分词器不同,同样的中文文本在不同tokenizer下token数可以差30%以上。
解决方法是组装前强制使用目标模型的tokenizer重新计数,不能拿一个固定enc算完到处用。同时给预算留出10%的安全边际。如果你用第三方模型或本地模型,估算可以使用len(text) // 2这类粗略公式,但必须在系统压测中确认边界。
4.4 摘要里丢了关键约束
长会话模式的摘要,我踩过最疼的一个坑:用户说"不要短信,请用电话联系我",摘要引擎没记住这条约束,结果后续回复里又建议发短信,用户直接投诉。后来我把摘要模板改成强制保留"用户的明确偏好与约束"字段,并且每次组装上下文时,把这个字段放到离system最近的memory位置。
经验是:摘要不是概括,而是关键信息提取。宁可多保留几条硬约束,也不要为了篇短文好看把约束去掉。一些团队会用"多级摘要"——底层保留原始摘要,上层再压缩,底层不丢失字段。有兴趣可以试试,效果稳定。
4.5 常见故障速查表
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 模型忘记用户目标 | 早期关键信息在history中被截断 | 检查history截断策略,考虑用摘要记忆 |
| 模型回复风格突变 | system block被意外裁剪 | 查看prompt日志,确认system出现且靠前 |
| 知识问答答非所问 | 检索召回噪声 | 加rerank、降top_k、加来源标签 |
| 请求报超限 | token估算不匹配 | 换用目标模型tokenizer,预留10%余量 |
| 多轮后对话质量暴跌 | 旧指令未迁移到摘要 | 摘要模板强制提取约束字段 |
| 每次调用延迟很高 | 塞入过多不必要block | 分析各block token占比,做瘦身 |
5. 让context-mode更好用的几个落地技巧
5.1 给prompt打标签,输出"上下文体检报告"
我在开发环境里会做一个可视化工具:把组装后的prompt按block渲染出来,标注每个block的token数和占比。它长这样:
[system ] 1200 tokens (18%) [memory ] 600 tokens ( 9%) [retrieved ] 1800 tokens (27%) [history ] 2400 tokens (36%) [user ] 200 tokens ( 3%) [reserved ] 800 tokens (12%)每次测试用例跑完,我只截图看这个"体检报告"。如果哪类block异常膨胀,一眼就能定位到是业务数据量太大了还是memory没生效。这个习惯帮我省了很多调试时间,强烈建议你也做一层。
5.2 用动态优先级代替固定优先级
前面的例子都是静态priority,但真实场景里优先级应该随状态变化。例如用户在确认订单时,订单号字段的优先级要临时拉高;用户在闲聊时,历史对话优先级可以降低;进入售后流程后,业务规则block优先级拉到最高。
实现上很简单:在ContextAssembler组装前,由状态机更新各block的priority。核心是思路转换——不要觉得优先级是写死的配置,它可以是会话状态的函数。
5.3 测试时务必加一组"上下文对抗样本"
我团队里有个playbook,每次改context-mode相关代码,至少跑下面这三组对抗样本:
- 中途改需求:用户先要求A,聊了几轮后要求B,看看旧指令是否污染新指令
- 超长上下文:故意灌入超过预算1.5倍的内容,检查关键约束是否保留
- 检索噪声:在知识库召回里塞入一个明显不相干的文本,看模型是否被带偏
这三组样本能覆盖大部分context-mode的雷区。别看它们简单,很多线上事故只要跑过对抗样本就能提前发现。
5.4 可直接copy的配置参考
这里给一组我实践下来比较稳的配置,供你当初始参数用:
| 场景 | 总预算 | system | 摘要 | 检索 | 历史 | 输出预留 |
|---|---|---|---|---|---|---|
| 通用客服 | 8000 | 800 | 1200 | 500 | 3200 | 800 |
| 文档问答 | 6000 | 600 | 300 | 2200 | 800 | 800 |
| Agent工具调用 | 10000 | 1000 | 500 | 1000 | 3000 | 2000 |
| 单轮工具API | 2000 | 800 | 0 | 0 | 200 | 600 |
注意总预算中要给输出预留足够空间,尤其是Agent场景,CoT的中间推理可能很长。我见过不少团队把预算死扣在输入上,输出被系统截断,效果大打折扣。
5.5 不盲目上复杂度:小应用用固定模式够了
最后想感叹一句,context-mode虽好,但复杂度也是成本。如果你做的只是一个工具类API调用、一个几十行的小脚本,固定使用短对话模式加少量历史就够了。模式切换、摘要引擎这些重型组件,应该在你真正遇到"长对话崩坏"或"知识问答不准"之后才引入。
我自己的原则是:先复制一个最简单的固定模式上线,然后监控三类指标——坏回答率、平均响应token、超限率。当坏回答率确实和会话轮数相关时,再做长会话模式;当用户高频追问文档内容时,再做知识增强模式。这个渐进式策略,比一开始就设计一堆模式然后没人用要靠谱得多。
如果你问我做context-mode最大的体会是什么,我会说:上下文管理的本质不是“让模型看到更多”,而是“让模型在合适的时间看到合适的信息”。这个思路不仅适用于大模型应用,放到任何信息处理系统里都成立。每次改版前,我都会先问自己一句:当前场景下,哪些信息是模型最缺的?哪些是可以牺牲的?想清楚再动手,效果自然就稳了。