ARTICLE DETAIL

资讯详情

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

大模型应用上下文管理:四种模式与避坑指南

大模型应用上下文管理:四种模式与避坑指南 做 AI 应用半年我踩过最深的坑就是对话上下文的管理。记得第一次把带聊天记忆的客服机器人丢上测试环境用户问了三轮“你们怎么退款”模型第三轮开始一本正经地推荐起隔壁竞品来——原因很简单前两轮的对话内容被塞爆了最新的系统指令被硬生生挤出了上下文窗口。从那天起我才明白context-mode不是参数面板里一个可以随便拨的开关而是决定 AI 应用能不能真正落地的那条生命线。这里我讲的 context-mode指的是在 LLM 应用开发中针对“上下文窗口”的读取策略、组织方式和淘汰机制所形成的一套配置体系。它直接决定了模型能“记住”什么、该忽略什么也直接关系到应用的响应质量、Token 成本和并发性能。这篇文章会把我在多个项目里用到过的 context 方案完整拆开四种主流模式怎么选、窗口参数怎么算、混合模式怎么落地以及那些文档里不会写的翻车事故和排查思路。适合正在做聊天机器人、文档问答、智能体Agent应用的开发者参考不管你现在用的是哪家大模型 API这套思路都是通用的。1. context-mode 到底是什么为什么我劝你一定要重视它1.1 从一个翻车现场说起先还原一下当时的真实场景。客户要求做一个“带记忆”的售后客服机器人我的第一版实现非常简单粗暴把整个对话历史原封不动地拼进 API 请求里一起发给模型。跑通 demo 的时候一切正常模型聊得头头是道客户也很满意。结果一到真实对话用户七拐八绕聊了三四十句问题来了。我用的模型上下文窗口是 8K约 8000 token消息内容一长请求就报错context_length_exceeded。我开始手动截断只保留最近几轮对话。可模型开始“失忆”用户第一次提问时留了订单号到第五轮问“那我这笔单什么时候能退”时模型已经完全想不起来订单号这回事。后来翻了模型官方文档我才意识到一个扎心的事实开发者自定义的 system prompt 和用户早期输入的历史消息在模型眼里地位是一样的。谁的 token 先用完谁就先被挤出去。当时我为了塞进更多聊天记录把 system prompt 里的业务规则压缩得只剩一句“你是客服助手”结果模型遇到稍微超出规则的问题就自由发挥回复口径完全失控。这个翻车案例基本把 context-mode 要解决的三大问题全暴露了窗口溢出、关键信息丢失、系统指令被稀释。所以后面我做所有 AI 应用第一件事就是先定义“这条对话里到底什么东西永远不能丢”再去谈实现。1.2 context-mode 的本质是在管理两样东西说到底context-mode 就是在管理两样东西信息优先级和Token 预算。信息优先级解决的是“什么该留、什么该扔”。一段对话里信息天然分为几层全局固定层系统提示词、用户画像、业务规则几乎不变、会话记忆层本次对话里用户提过的关键事实比如订单号、地址、偏好、短期缓冲层最近几轮问答的具体措辞用来保持语气连贯以及临时噪声层客套话、重复内容、离题话。一个设计良好的 context-mode本质上就是给这几层信息分配合适的“座位”保证优先级高的永远不被挤走。Token 预算解决的是“窗口就那么大怎么分”。这里的核心不是“塞满”而是“留白”。我自己的经验是任何情况下请求里的 token 总量不要超过模型窗口上限的 70%——剩下 30% 要留给模型的推理空间。这个 70% 是经验值不是官网参数窗口是模型计算时能“看到”的极限但生成回答也需要占 token如果请求体把窗口塞到九成满模型来不及组织答案就被截断了。明白了这两件事再看市面上各种 context 方案思路就会清晰很多。它们本质上都是“不同优先级策略 预算分配策略”的组合没有哪个绝对最好只有适合不适合你的场景。2. 四种常见上下文模式每种都有它的脾气2.1 滑动窗口模式最直观但最“健忘”滑动窗口Sliding Window的逻辑是人最容易理解的只保留最近 N 轮对话更早的全部丢弃相当于队列先进先出。实现上也很简单。每次请求前从消息数组尾部往前数 N 条拼起来发给模型def build_sliding_window(messages, max_rounds6): recent messages[-max_rounds:] if len(messages) max_rounds else messages return [{role: system, content: SYSTEM_PROMPT}] recent这套方案最大的优点是便宜、快、实现零成本。但它的“健忘”是硬伤。用户在第 2 轮说了“我住在朝阳区”等聊到第 10 轮问“附近有什么餐厅”模型根本不知道“附近”是以哪里为中心。很多团队把这种模式用在闲聊型机器人上效果还行因为闲聊本身就不需要极强的记忆。不过有一个细节很多人会忽略滑动窗口的“N”不能只按轮数算还要按 token 算。有段时间我固定取最近 8 轮用户机翻式长文本提问一次发来上千 token8 轮直接把预算吃穿。后来改成按 token 总量逆向截取消息从尾部往前累加达到预设 token 阈值就停比按轮数更稳def build_token_budget_window(messages, max_tokens3000): result [] used 0 for msg in reversed(messages): cost estimate_tokens(msg[content]) 4 # 角色开销 if used cost max_tokens: break result.insert(0, msg) used cost return result2.2 摘要压缩模式让模型自己给自己写笔记滑动窗口的进阶版是摘要压缩Summarization / Recursive Summarization。核心思路不复杂把早期对话定期“浓缩”成一段摘要塞进上下文里当背景信息完整的历史则丢到外部存储。我第一次在一款数据分析助手里用了这个模式。用户和助手连续聊了两天每天产生大量图表操作记录如果全量塞进窗口系统早就崩了。后来我只保留一个不断更新的“对话纪要”每次对话开始前把昨天的纪要 今天最近几轮原文拼在一起发给模型。实现时要解决一个关键技术点摘要的“摘要”怎么生成。方案是递归摘要每积累到一定轮数把旧摘要和新消息一起丢给模型让模型生成一份全新摘要。下面这段是我实际在用的 prompt 模板summary_prompt 你是一个对话记录员。请阅读以下旧摘要和新对话生成一份新版摘要。 要求 1. 保留所有具体数值、专有名词、用户明确表达过的偏好 2. 删除寒暄、重复、离题内容 3. 总长度不超过 {max_tokens} token 4. 只输出摘要不要输出任何解释 旧摘要 {old_summary} 新对话 {new_messages} 摘要压缩模式解决了长周期记忆问题但它有一个致命缺点摘要行为本身会造成“信息坍缩”。模型在压缩时一定会丢弃一些“它认为不重要”的信息而这些信息往往恰好是后续对话里需要翻出来的关键。比如用户说过“我不吃香菜”摘要模型可能因为上下文里菜谱信息太多把这句随手带过的话压缩没了。所以我现在只有在“对话周期长 单轮价值密度低”的场景才用纯摘要模式比如长期陪伴类助手、日志分析 Copilot。如果单轮对话包含强事实约束比如订单号、价格、地址我会换下一种模式。2.3 检索扩展模式让上下文“按需加载”检索扩展模式也就是常说的 RAGRetrieval-Augmented Generation思路不把全部历史或全部知识塞进上下文而是根据用户当前的问题先从外部存储里“捞”出最相关的片段再拼进请求。这个模式在文档问答场景里几乎是标准答案。我做过一个内部知识库机器人知识库里有几百份 PDF总量超过 20 万 token根本不可能全塞进上下文。我当时的方案是把知识库切成固定长度的 chunk我用的 500 token 重叠 50 token效果比较理想。用 embedding 模型把每个 chunk 转成向量存进向量数据库用的是 pgvector省得引入额外组件。用户提问时把问题转成向量做相似度检索取 top 5 chunk。把检索到的 chunk 作为“参考资料”拼进 system prompt 或 user message。def build_rag_context(question, top_k5): embedded_q embed(question) chunks vector_db.search(embedded_q, top_ktop_k) ref_text \n\n.join([f[文档片段{i}]: {chunk} for i, chunk in enumerate(chunks)]) return f 请根据以下参考资料回答用户问题。如果资料中没有相关信息请明确回答“资料库中未找到”。 参考资料 {ref_text} 检索扩展模式最大的优势是成本可控、知识面无限扩展。但坑也很深检索效果直接决定回答质量而检索的“相关性”本身就是一个难优化的指标。用户问“这个月还能不能报销”chunk 里全是“报销流程”“报销制度”“报销限额”向量相似度可能反过来把“报销流程”排在最前把真正写着“月底截止”的那一条挤到后面去了。用这个模式必须配套做召回评估不能只看单个问题答得准就上线。我后面专门用一组 50 条的真实用户问题做了评测集每条标注正确答案对应的 chunk ID再调 embedding 模型和 chunk 切分方式才把召回率从 62% 拉到 85% 以上。2.4 混合分层模式生产环境最常见的落地方案到了真正上生产的复杂应用我几乎不会用上面任何一种单一模式而是混合分层。什么叫混合分层就是把全局指令、会话摘要、短期窗口、检索片段按优先级和角色拼进同一个请求每一层只占一个预设的 token 预算。下面是我在一个人力资源问答助手里用过的分层配置样例{ context_mode: hybrid, layers: [ { name: system_rules, source: static, max_tokens: 800 }, { name: user_profile, source: database, max_tokens: 300 }, { name: conversation_summary, source: live_update, max_tokens: 1200 }, { name: recent_messages, source: sliding_window, max_tokens: 1500 }, { name: retrieved_chunks, source: vector_search, max_tokens: 1000 } ], total_budget: 4800 }这个配置的含义是系统规则固定在顶部用户资料从库里实时加载早期对话用摘要呈现最近几轮按原文保留遇到特定咨询触发知识库检索。然后所有层加起来控制在 4800 token 以内给生成回复留出约 1400 token按 8K 窗口算。混合模式的优势很明显不同信息各归其位不太容易出现“关键信息被挤出去”的极端情况。但代价是构建逻辑变复杂每一层何时触发、何时降级、各层之间互相冲突时谁优先都要明确规则。比如用户资料的 token 预算被占满时是裁掉用户画像还是裁掉会话摘要我的答案是优先保留最新且与当前轮相关的信息这种取舍要写进代码不能每次运行时重新决策。3. 实操示例给文档问答助手设计一套 context-mode3.1 明确你的“上下文预算”有了前面四种模式的铺垫这一步来做个完整的实操演练。目标应用是一个电商客服文档问答助手主要任务是根据售后政策文档回答用户问题同时要记住用户在本次会话里交代过的订单号和个人信息。先确定用的是 GPT-4o 的 8K 上下文窗口128K 窗口的模型也同理按比例计算就行。直接拉满用是不行的我按下面这个公式分配组成部分分配 token计算依据System Prompt业务规则、回答风格600固定值只写永不变的规则会话摘要早期对话压缩800上限值超了就滚动压缩最近对话原文滑动窗口2000大约覆盖最近 3-5 轮检索结果知识库片段1000取 top 3 chunk每段约 330 token预留生成空间2000模型输出响应所需的余量合计6400约占 8K 窗口的 80%偏饱和但可控这里多说一句为什么留 2000 token 给生成实测经验是文本生成的 token 数不是固定值客服回复短则一两百长则接近千 token。如果生成预算只留 500一旦模型回答稍长就会“截断”返回的 content 戛然而止用户端拿到的是一段没说完的话。2000 的余量对客服场景来说比较稳。如果你做的是代码生成或长文写作这个预留值要再上调到 3000-4000。3.2 消息结构设计与上下文填充逻辑确定预算之后我把发给模型的messages设计成四段顺序很重要messages [ {role: system, content: SYSTEM_RULES}, # 1. 全局指令 {role: system, content: MEMORY_DIGEST}, # 2. 会话记忆摘要 {role: user, content: RETRIEVAL_CONTEXT}, # 3. 参考资料以 user 身份注入 # 4. 真实对话轮次按 token 预算逆序填充... ]为什么参考资料要以user身份而不是塞进 system我踩过一次坑。原本我把检索片段和系统规则拼在同一个 system 里结果模型经常把“参考资料”也当成“必须遵守的规则”回答口吻变得很怪有时还把资料里的不同来源冲突当成规则矛盾反复道歉。后来改成单独一条 user 消息前面标注“以下是参考资料”模型就明白这是“背景知识”而非“行为准则”。会话摘要的生成时机我设在每个对话轮次结束时判断如果累积的原始消息 token 超过了 3000就触发一次摘要更新。摘要本身不删旧摘要而是“旧摘要 新消息 → 新摘要”保证信息不重复丢。这一步会额外消耗几百 token但对比重发全部历史来说省得多。3.3 完整实现流程从输入到回复整条链路我分成了六步线上跑起来非常稳定第一步接收用户消息先查会话存储我用 Rediskey 是session:{id}:history确认当前会话有没有历史摘要和近期消息。第二步把新进来的用户消息追加到 Redis 的近期消息列表同时更新“最后活跃时间”。第三步根据当前问题的类型决定是否需要检索。我设了一个简单的规则问题里出现“能不能、怎么、是否、规定、流程、多久”这些词就触发知识库检索出现“我的订单、我买了、我上次”之类则优先从会话摘要里提取信息不触发检索。第四步执行检索如果触发了把命中的 chunk 按设定好的格式拼接成参考资料块。第五步按预算组装 messages。这个过程要写成一个纯函数输入是摘要、近期消息、检索块输出是拼好的 messages 数组。组装时用一个 token 计数器来算超出预算就从“近期消息”里从旧到新丢弃绝不能动摘要和检索块。第六步调用模型 API。拿到回复后把回复也追加进近期消息再次检查 token 累计值决定是否触发摘要更新。def assemble(session, retrieval_blocksNone): budget_system 600 budget_summary 800 budget_recent 2000 budget_retrieval 1000 messages [ {role: system, content: SYSTEM_RULES}, {role: system, content: session.summary}, ] if retrieval_blocks: messages.append({role: user, content: retrieval_blocks}) recent session.recent_messages used_tokens sum(estimate_tokens(m[content]) for m in messages) for msg in reversed(recent): cost estimate_tokens(msg[content]) if used_tokens cost budget_recent: continue messages.append(msg) used_tokens cost return messages3.4 复杂度与成本估算有朋友问过我这套方案的成本怎么样。拆开算一笔账假设每次请求最终发送的 token 量在 5000 左右模型回复约 300 token。按 ChatGPT 的价格一进一出合计约 5300 token按 1K token 0.0025 美元算单次对话约 0.013 美元。如果每天 1 万次请求一天约 130 美元。这套方案和“全量历史直接塞”相比省下来的主要是历史越长越惊人的输入费用。我做个对比完全不管理上下文的应用聊到 30 轮时每次请求输入可能涨到 1.5 万 token单次成本翻三倍不止。而且模型处理长度越长首字延迟越高用户端体感就是“越聊越卡”。context-mode 省的不只是钱还有延迟。4. 六个高频事故与排查经验速查4.1 事故一模型“突然失忆”多轮对话答非所问现象很典型前三轮还好好记住用户的订单号第四轮问“那可以改地址吗”模型反问“什么订单号”。排查路径我固定分三步。第一步打开 API 请求日志核对发给模型的 messages 里到底有没有订单号。很多时候你会惊讶地发现订单号在摘要压缩时被吞了或是在滑动窗口淘汰时被弹出去了。第二步如果消息里确实有再看 token 顺序订单号是不是被放得离问题太远被大量的中间轮次稀释了注意力——这是真实存在的情况模型“中间丢失”现象在长上下文中非常明显。第三步如果是摘要导致丢失那就需要调整摘要 prompt给“订单号、金额、地址、截止时间”这类字段设强制保留规则。我的解决动作是在摘要 prompt 里加了一句“任何数字、金额、单号、日期、地址、人名必须原样保留”实测摘要丢关键信息的概率降了很多。4.2 事故二Token 统计和真实消耗对不上我一度用len(content)当 token 估算结果模型动不动报超限。后来才搞明白中文字符一个可能等于 1 到 2 个 token代码和特殊符号另算模型 API 的 tokenization 规则和我猜的完全不是一回事。正确的做法是用模型提供方的 tokenizer 库来算。如果你用 OpenAI 的模型tiktoken是标配import tiktoken enc tiktoken.encoding_for_model(gpt-4o) def estimate_tokens(text): return len(enc.encode(text))如果用的是其他模型至少也要找到对应的 tokenizer不要用字符数乘系数来糊弄。我见过一个排查了很久的问题就是线上服务用字符数估算用户的英文订单号加标点全是按 1 算的实际 token 膨胀了快两倍导致真实请求超限报错时人们都一脸懵。4.3 事故三摘要压缩后数据丢失递归摘要跑久了有一个隐蔽问题摘要再摘要细节一层层磨损。第一次摘要还能保留 80% 细节第二次基于旧摘要生成可能只剩 60%压缩三轮之后连用户最初问的主题都变得模糊。排查这类问题要看摘要变更记录。我给摘要对象加了一个version字段每次更新时递增。如果发现某个关键字段在某个版本出现、在下一个版本消失基本就是摘要 prompt 或者压缩策略的问题。解决方案不是把摘要无限加长而是给摘要设置“强制保留字段”“外部结构化存储”双保险订单号、用户名这类结构化信息直接从对话里抽出来存进数据库字段不依赖摘要去记。4.4 事故四检索出来一堆“看似相关”但没用这是 RAG 场景最普遍的坑。当时我把知识库切成 500 token 的 chunk 后发现用户问“退货要多久”检索命中的前三条全是“退货政策概览”“退货流程”“退货常见问题”看起来相关但真正写了“收到退货后 3 个工作日内退款”的句子藏在一个大 chunk 的中部向量相似度被 chunks 里其它无关句子拉低了。解决思路有两个方向。第一改 chunk 策略切得更小256 token让每个片段主题更集中第二用更大的 top_k比如从 3 扩到 5让更多 fragment 被召回再让模型筛选但注意这占用了检索块 token 预算。第三我给每个 chunk 写了 summary tag检索时优先匹配 tag 再匹配向量召回精度有明显提升。这里的教训是RAG 不是一个 embedding 模型就完事的系统chunk 策略、索引结构、召回线都需要单独调。4.5 事故五上下文注入顺序导致模型被带偏有一个很有意思的现场。我把新的用户问题放在最前面检索资料放在最后结果模型把“检索资料里的旧说法”当成了当前目标回答内容被绕偏。大语言模型对消息顺序非常敏感靠近开头和结尾的内容更容易被记住中间部分容易“滑过去”。所以在组装 messages 的时候《当前用户的最新问题》一定要放在最后一条也就是模型要直接“回应”的位置检索资料作为参考资料放在它前面即可。如果检索块和系统规则冲突例如知识库文档更新了规则没同步改模型多半会优先听 System Prompt 的必须留意这种优先级带来的“信息僵化”。4.6 事故六并发场景下 Session 上下文串线这个事故最隐蔽。高并发下我为了省 Redis 连接给 Session 存储加了个全局缓存结果 A 用户的摘要被 B 用户请求读到了。用户问“我买的红色那件什么时候发货”模型回答“您的蓝色订单明天送达”简直社死现场。排查之后确定是缓存 key 设计问题只用了简单的自增 ID没加会话维度隔离。修复方案缓存 key 必须包含session_id且每次读写都要做二次校验绝不能把用户维度信息放在全局池子里。同时给会话摘要、最近消息、检索块的缓存分别设置不同的过期时间避免一个 session 的数据异动影响其它 session。这个事故给我们的提醒是context-mode 不是单请求的逻辑它是一整套围绕“会话生命周期”的状态管理方案。5. 一点个人心得别把 context-mode 当成事后补救做过的项目越多越觉得 context-mode 应该是在需求分析阶段就确定的设计决策而不是上线前用来“救火”的补丁。你甚至可以在产品 PRD 阶段就问三个问题这个应用需要记多久的对话哪些信息丢了会出事故单次回答最长要写多长答案一出来适合的上下文模式基本就定了一半。我个人的偏好是任何面向用户的正式产品都从混合分层模式起步哪怕第一版只用其中两层。因为后续迭代要加记忆功能时有分层骨架在扩展起来数据库和检索层可以直接挂进去如果一开始就用简单的滑动窗口后面改的时候几乎要把整个会话模块重写一遍。另外还想给一个小建议context-mode 要配套可观测性。光看模型回复好不好是不够的要记录每次请求发送出去的 messages 结构、各层 token 占比、摘要更新历史、检索命中的 chunk ID。遇到线上问题这些日志能让你十分钟定位到是上下文哪一层出了岔子而不是翻半天代码也找不到头绪。我就因为这些日志救回过两个差点回滚上线的版本这个习惯值得每一位做 LLM 应用的同行养成。
返回列表