ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

上下文模式(Context-Mode)设计:让大模型在复杂场景下稳定输出

上下文模式(Context-Mode)设计:让大模型在复杂场景下稳定输出 做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_factorydict)为什么用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_budget8000, modelgpt-4o, reserve_for_output800): 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, keylambda b: b.priority, reverseTrue) 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_typeblock.block_type, contenttruncated_text, priorityblock.priority, token_countremain, 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(fsystem\n{block.content}\n/system) elif block.block_type summary: sections.append(fmemory\n{block.content}\n/memory) elif block.block_type retrieved: for item in block.content: sections.append(fcontext source\{item[source]}\\n{item[text]}\n/context) elif block.block_type history: sections.append(fconversation\n{block.content}\n/conversation) sections.append(fuser\n{user_text}\n/user) return \n.join(sections) \n\nassistant注意最后加了assistant模型会被引导接着这个token继续补全回复会更自然不会重复用户的问题。3.4 模式选择器和完整调用示例组装器有了还差一个模式选择器。我用一个简化版展示思路核心逻辑是读会话状态返回一个配置配置决定组装哪些block以及怎么分配预算。class ContextModeSelector: def __init__(self, message_window12, retrieval_threshold0.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, retrieverNone, memoryNone, llmNone): mode selector.select(state) blocks [ContextBlock(system, SYSTEM_PROMPT, priority100)] if mode knowledge and retriever: hits retriever.retrieve(user_text, top_k4) if hits: blocks.append(ContextBlock(retrieved, hits, priority80)) if mode in (long_session, agent): summary_text memory.get_summary(state[session_id]) if summary_text: blocks.append(ContextBlock(summary, summary_text, priority90)) recent_history state.get_recent_messages(window8) if recent_history: blocks.append(ContextBlock(history, recent_history, priority60)) 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摘要检索历史输出预留通用客服800080012005003200800文档问答60006003002200800800Agent工具调用100001000500100030002000单轮工具API200080000200600注意总预算中要给输出预留足够空间尤其是Agent场景CoT的中间推理可能很长。我见过不少团队把预算死扣在输入上输出被系统截断效果大打折扣。5.5 不盲目上复杂度小应用用固定模式够了最后想感叹一句context-mode虽好但复杂度也是成本。如果你做的只是一个工具类API调用、一个几十行的小脚本固定使用短对话模式加少量历史就够了。模式切换、摘要引擎这些重型组件应该在你真正遇到长对话崩坏或知识问答不准之后才引入。我自己的原则是先复制一个最简单的固定模式上线然后监控三类指标——坏回答率、平均响应token、超限率。当坏回答率确实和会话轮数相关时再做长会话模式当用户高频追问文档内容时再做知识增强模式。这个渐进式策略比一开始就设计一堆模式然后没人用要靠谱得多。如果你问我做context-mode最大的体会是什么我会说上下文管理的本质不是“让模型看到更多”而是“让模型在合适的时间看到合适的信息”。这个思路不仅适用于大模型应用放到任何信息处理系统里都成立。每次改版前我都会先问自己一句当前场景下哪些信息是模型最缺的哪些是可以牺牲的想清楚再动手效果自然就稳了。
返回列表