ARTICLE DETAIL

资讯详情

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

Context Mode上下文模式:大模型输出不稳定的根源与实战方案

Context Mode上下文模式:大模型输出不稳定的根源与实战方案 最近在折腾一个内部 AI 工具遇到一个特别典型的场景同一个模型代码生成、文档问答、数据分析都要用结果发现不管我怎么调提示词输出质量都不稳定。一会儿还挺靠谱一会儿就东拉西扯甚至把上一个任务的上下文带到了下一个任务里。排查到最后问题不是模型不行而是“context-mode”——上下文模式——没有设计好。上下文模式这个概念说白了就是在和大模型交互时你和它之间维持的那一段“共同记忆”到底应该包含什么、长什么样、怎么更新。很多人觉得这不就是“多轮对话历史”嘛其实远没那么简单。你用同一个模型做问答、做代码审查、做周报生成需要的上下文结构是完全不一样的。如果把所有东西都塞在一个模式里模型就会把不该关联的内容强行关联表现自然拉胯。这篇文章我想围绕“context-mode”这个主题把怎么设计、怎么实现、怎么排查上下文模式完整梳理一遍。适合正在做 AI 应用开发、或者用大模型 API 搭工具的人看哪怕你只是用 ChatGPT 这类产品理解了这套思路你写提示词的水平也能上一个台阶。我会从一个实际的小项目切入把思路、代码、坑都讲清楚。1. 被低估的“上下文模式”先想清楚这四层结构1.1 为什么同一个模型有人用得像专家有人用得像玩具很多人在使用大模型时有一个错觉模型聪明所以我只要问题问得对就能得到正确答案。但实际情况是大模型在每次生成时只知道你塞给它的那一堆 token它的“聪明”完全建立在这堆 token 的上下文之上。同样的模型你给它一段代码加一句“帮我审查”它能指出潜在 bug你给它一段业务描述加一句“帮我审查”它可能就开始编代码了——不是它笨是上下文模式不对。我见过最多的问题是把所有任务都跑在一个“通用聊天模式”里。用户今天问菜谱明天让写 SQL后天让修 bug上下文里全是杂七杂八的内容。表面上看模型“记住”了前面的话实际上这些无关信息会让模型把注意力分散到错误的地方。这就像一个人脑子里同时记着十个项目你让他马上处理第十一个他一定会把前面项目的习惯带进来。所以上下文模式的核心任务是为不同类型的任务划定不同形状的记忆边界。不是所有上下文都是一整段对话历史而是要按照任务性质决定哪些信息进入、哪些信息过滤、哪些信息需要做摘要压缩。设计好这层结构模型的能力才能真正发挥出来。1.2 上下文模式的四种类型与对应场景我自己的实践里会把上下文模式分成四类每一类的结构和用途完全不同第一类是“任务指令模式”典型场景是单轮问答、代码生成、翻译、总结。这模式下上下文的主体是系统提示词加当前请求不需要保留太多历史。模型要的是一个干净、聚焦的指令环境。你给它一堆历史对话它反而容易被带偏。第二类是“多轮对话模式”典型场景是客服对话、聊天机器人、交互式编程助手。这时候上下文需要维护一段连续的消息列表并且要控制窗口长度。关键不是把所有历史都堆进去而是要让模型始终知道自己“当前在聊什么”。通常的做法是保留最近几轮加上一轮摘要比如“用户之前想知道支付失败的原因目前还没有解决”。第三类是“检索增强模式”典型场景是知识库问答、文档分析、私有数据查询。这个模式里上下文的主体是检索回来的内容片段历史对话通常只留当前问题。你需要做的是把用户的问题变成检索 query把命中的文档片段拼装进上下文再明确告诉模型“以下内容来自知识库请基于它们回答”。这种模式最怕的是把用户问题和文档内容混在一起不加分隔模型会分不清哪些是资料、哪些是问题。第四类是“结构化输出模式”典型场景是数据抽取、内容分类、批量生成。这类模式对上下文的精确度要求极高你需要在上下文里明确输出格式约束、字段说明、示例样本并且严格避免无关历史信息。模型一旦看到了别的例子输出的格式就容易跑偏。有了这四类划分“context-mode”就不是一个模糊的概念而是一套可以落地设计的技术方案。接下来我讲讲怎么用这些思路去搭一个真实可用的工具。2. 实战拆解一个支持多模式切换的上下文工具2.1 项目整体设计与工具选型为了把前面的思路讲透我写了一个小的 Python 工具一个支持多上下文模式切换的 CLI 助手。它核心解决三个问题第一不同的任务类型有独立的上下文结构第二模式之间可以显式切换不会串味第三每次调用能清楚地看到上下文里到底有什么。技术选型上我用了 Python 3.10 加 OpenAI SDK模型用的 gpt-4o-mini。选 Python 是因为做原型快、生态好OpenAI SDK 的结构清晰适合演示上下文怎么拼装。你不用在意具体用什么模型厂商核心逻辑是通用的。我还用了typer做命令行交互pydantic做配置校验rich做终端日志打印。这些都是常规选择如果你不想引入太多依赖用 argparse 也能完成。项目目录我建议这样组织context-mode-demo/ ├── main.py # 入口与模式路由 ├── modes.py # 上下文模式定义 ├── history.py # 会话历史管理 ├── retriever.py # 检索模块本地简单实现 └── config.yaml # 各模式参数配置这个结构不复杂但每个文件职责清楚。后面所有的实操内容都是在这个结构上展开的。你要是自己搭也可以先不管检索模块把前三个文件跑通再说。2.2 模式定义把“提示词”变成可配置的结构体我强烈建议不要把所有提示词散落在代码里。你一旦需要调优散落的字符串会变成灾难。更好的做法是把每种上下文模式定义成一个结构体包含系统提示词、消息窗口大小、是否启用检索、输出格式要求等字段。我在modes.py里是这样定义的from dataclasses import dataclass, field from typing import Optional dataclass class ContextMode: name: str system_prompt: str window_size: int 10 use_retrieval: bool False retrieval_top_k: int 3 output_hint: str max_tokens: int 1024 MODES { qa: ContextMode( nameqa, system_prompt( 你是一个严谨的技术助手。请基于给定的资料回答用户问题。 如果资料中没有答案请直接说明不得编造。 ), window_size2, use_retrievalTrue, retrieval_top_k3, output_hint回答请控制在200字以内用Markdown格式。, max_tokens600, ), code_review: ContextMode( namecode_review, system_prompt( 你是一名资深代码审查工程师。请从正确性、性能、可维护性、 安全性四个维度检查代码指出问题并给出修改建议。 ), window_size1, use_retrievalFalse, output_hint每个问题用列表呈现并在最后给出综合评级。, max_tokens1500, ), weekly_report: ContextMode( nameweekly_report, system_prompt( 你是一名项目助理。请根据用户提供的工作记录生成结构化周报。 周报要包含本周完成、风险与问题、下周计划三个部分。 ), window_size3, use_retrievalFalse, output_hint输出格式为Markdown每部分不超过5个要点。, max_tokens800, ), }这段代码看起来普通但有几个细节很关键。第一window_size控制历史轮数qa 模式因为主要靠检索资料历史保留 2 轮就够了code_review 几乎不需要历史1 轮即可。第二use_retrieval决定是否走检索流程qa 和 code_review 的上下文构建路径完全不同。第三max_tokens是控制输出长度的不能所有模式都用一个值。你可能会问为什么要这样拆分因为同一个模型如果不加区分地共享一套提示词和窗口参数几乎必然出现互相干扰。比如做代码审查时如果模型上下文里还残留着周报模式的提示词它写出来的“问题列表”可能就是周报式的“项目进展”完全跑偏。2.3 窗口管理与 Token 预算别让上下文悄悄爆掉窗口管理是“context-mode”里最容易被忽略的部分。很多人以为只要不超过模型的最大 token 限制就行实际上上下文里塞得越多模型对关键信息的注意力就越稀释而且响应时间和成本都会上升。你需要给每个模式设定一个明确的 token 预算而不是让会话无限增长。我在history.py里实现了一个简单的滑动窗口from collections import deque class SessionHistory: def __init__(self, max_rounds: int 10): self.messages deque(maxlenmax_rounds * 2) # 每条消息含user和assistant def add(self, user_msg: str, assistant_msg: str): self.messages.append({role: user, content: user_msg}) self.messages.append({role: assistant, content: assistant_msg}) def to_openai_messages(self, system_prompt: str): result [{role: system, content: system_prompt}] result.extend(list(self.messages)) return result def clear(self): self.messages.clear()一个值得注意的点是deque(maxlen...)会在超出长度时自动丢弃最旧的消息这比每次手动裁剪列表要优雅得多。但是滑动窗口有一个很难受的缺陷——一旦关键信息在旧消息里就会被直接丢掉。比如你在 qa 模式下问了“A 方案和 B 方案有什么区别”十轮后又问“那它们的成本对比呢”模型可能已经完全忘了 A、B 是什么。针对这个问题我通常会在滑动窗口之上加一层“记忆摘要”。摘要本身也是一条特殊消息放在窗口最前面每次对话超过 N 轮后调用模型把前面的关键信息压缩成 2-3 句话然后把摘要作为一条 “system” 级别的消息加入真实的历史消息则从窗口里移除。这样既控制了 token又不至于丢失关键主题。具体实现我会在后面讲调用链的时候展示。3. 核心逻辑实现从函数到调用链3.1 实现一个上下文模式切换器入口这块我设计的核心是一个路由函数根据用户输入的/mode:qa这类命令切换当前上下文模式切换时自动清空历史避免模式之间的信息串扰。你也可以做成自动识别模式但显式切换在工具类场景下更可靠。这里有个设计取舍值得说清楚为什么不自动识别任务类型我一开始也尝试过用模型判断“这个用户输入属于哪一类任务”结论是没必要。多一次模型调用就多一次延迟和成本而且判断错了反而带来更差的体验。在自有工具里直接让用户用一个简短命令指定模式是最朴素、最稳定的做法。你要做的是把命令做得足够短比如/ctx qa、/ctx code。下面是main.py的核心部分import typer from rich.console import Console from modes import MODES from history import SessionHistory from llm import call_llm app typer.Typer() console Console() current_mode qa history SessionHistory(max_roundsMODES[current_mode].window_size) app.command() def run(): global current_mode print(f当前模式: {current_mode}输入 /ctx 模式名 切换支持模式: {list(MODES.keys())}) while True: user_input input(\n ).strip() if user_input.lower() quit: break if user_input.lower().startswith(/ctx ): new_mode user_input.split( , 1)[1].strip() if new_mode in MODES: current_mode new_mode history SessionHistory(max_roundsMODES[current_mode].window_size) console.print(f[green]已切换模式: {current_mode}[/green]) continue else: console.print(f[red]未知模式: {new_mode}[/red]) continue answer call_llm(current_mode, history, user_input) history.add(user_input, answer) console.print(f[cyan]{answer}[/cyan])这里我做了两件容易被忽略的事情。第一切换模式时重建SessionHistory把max_rounds设为新模式的窗口大小。很多上下文污染问题本质上就是因为旧模式的历史带进了新模式。清空历史是干净切换的底线操作。你甚至可以更进一步在切换前把旧历史存到另一个 key 下方便用户切回去还能恢复但多数场景不需要。第二把历史访问集中到history对象里而不在call_llm内部直接留会话变量。这样做的好处是后续想加多会话管理多用户、多项目时只需要把SessionHistory换成按 key 索引的字典不需要改调用逻辑。3.2 如何把检索结果注入上下文检索增强模式的上下文构建是最讲究的。单纯把检索到的几个文档片段拼到用户问题前面效果往往一般。原因是没有告诉模型“哪些内容是可信资料哪些内容是当前问题”模型分不清边界回答容易含糊。我在retriever.py里实现了一个简化的 BM25 检索器重点不在算法精妙而在于展示“检索结果如何进入上下文”。本项目用的检索器是本地简单实现生产环境可以替换成向量数据库或搜索 API。先看代码import jieba import math from collections import Counter class BM25Retriever: def __init__(self, docs: list[str]): self.docs docs self.doc_tokens [self.tokenize(d) for d in docs] self.doc_count len(docs) self.avg_doc_len sum(len(t) for t in self.doc_tokens) / max(self.doc_count, 1) def tokenize(self, text: str): return list(jieba.cut(text)) def score(self, query_tokens, doc_index): doc_tokens self.doc_tokens[doc_index] doc_len len(doc_tokens) tf Counter(doc_tokens) score 0.0 k1, b 1.5, 0.75 for token in set(query_tokens): if token not in tf: continue df sum(1 for t in self.doc_tokens if token in t) idf math.log((self.doc_count - df 0.5) / (df 0.5) 1) tf_score tf[token] * (k1 1) / (tf[token] k1 * (1 - b b * doc_len / self.avg_doc_len)) score idf * tf_score return score def retrieve(self, question: str, top_k: int): query_tokens self.tokenize(question) scores [(i, self.score(query_tokens, i)) for i in range(self.doc_count)] scores.sort(keylambda x: x[1], reverseTrue) return [self.docs[i] for i, _ in scores[:top_k]]用 jieba 做分词是因为演示语料是中文。关键在于检索返回的结果需要被包装进上下文而不是简单拼接。我的做法是构造一个“资料块”每一段用分隔线括起来并在前面声明来源以下是知识库中与问题相关的资料片段请仅依据这些片段回答 --- 资料片段 1 --- 内容 --- 资料片段 2 --- 内容 用户的问题XXX这个结构很朴素但非常有效。模型一眼就能看出“资料”和“问题”的边界不会把资料里的陈述误当成自己的推理结果。如果你用的是 OpenAI 的 message 结构也可以把资料片段放在单独的 user 消息里再让用户问题作为第二条 user 消息但实测下来在同一段上下文里用分隔符显式区分对结果的控制更直接。3.3 日志和回溯调试上下文问题的钥匙很多人调大模型接口写了一大堆逻辑却不打日志。遇到输出不对就只能傻盯着输入和输出猜问题。上下文模式的调试最重要的手段是“看到每次请求实际上发送了什么”。你设计的模式再漂亮如果实际发到模型那里的消息和你预期的不一致一切都是白搭。我在llm.py里加了完整日志from openai import OpenAI import rich import json from modes import MODES client OpenAI() def build_context(mode_name: str, history, user_input: str): mode MODES[mode_name] messages history.to_openai_messages(mode.system_prompt) if mode.use_retrieval: from retriever import BM25Retriever # 这里换成分级加载避免重复初始化 retriever get_retriever_for_mode(mode_name) docs retriever.retrieve(user_input, mode.retrieval_top_k) context_block \n\n---\n\n.join([f资料片段 {i1}:\n{d} for i, d in enumerate(docs)]) # 把检索结果注入最后一条 user 消息之前 messages.append({role: user, content: f{context_block}\n\n用户问题{user_input}}) else: messages.append({role: user, content: user_input}) return messages def call_llm(mode_name: str, history, user_input: str): mode MODES[mode_name] messages build_context(mode_name, history, user_input) rich.print([yellow] json.dumps(messages, ensure_asciiFalse, indent2) [/yellow]) resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, max_tokensmode.max_tokens, temperature0.3, ) return resp.choices[0].message.content这个build_context函数是整个项目的枢纽它把“模式定义、历史窗口、检索结果”三块东西拼成最终的消息列表。打印消息是我每一轮调试都会保留的动作。生产环境你可能需要按日志级别控制但开发期这条黄色的 JSON 输出价值巨大。另一个我强烈推荐的调试习惯是给每个请求加一个session_id或request_id把上下文结构、检索到的文档 id、模型返回完整信息都记到同一个 id 下。等模式上线后你排查线上问题时会感激自己当时做了这件事。上下文模式的调优是一个持续过程没有日志审计就等于是盲人摸象。4. 常见问题与排查技巧实录4.1 模式串味了上下文污染这是我在这个项目里踩过最大的坑。表现是用户在 qa 模式问了几轮资料问题然后切换到 code_review 模式结果模型还会引用“根据之前的资料”把代码审查写成了资料解读。排查思路是这样的先看日志里切换模式后提交给模型的 messages是否包含了旧模式的 system prompt 和历史消息。如果包含了那一定是在切换时没有清空历史或者history对象被多个模式共享了。我在最初版本里把 history 定义成了全局单例切换模式只是改了current_mode导致 qa 的历史一直残留在 code_review 的上下文里。解决方式就是现在这套切换模式时重建 history。但还有一种更隐蔽的污染即使切换后历史清空了模型仍然可能输出旧任务风格的内容。这时候要检查 system prompt 是否足够强。比如 code_review 模式下system prompt 里要明确写“忽略所有与代码审查无关的上下文”这样即使模型内部有“记忆惯性”也能被拉回来。你还可以在每次切换时让系统先发一条“你现在是代码审查模式请开始新一轮审查”的消息对模型的定向很有帮助。4.2 窗口溢出不是报错了才算溢出当你调用大模型 API 出现 context length exceeded 报错说明窗口已经严重溢出了。但更多时候窗口溢出没有报错只有输出质量肉眼可见地下降。模型的注意力是有限的当你的上下文塞满了检索片段、历史对话、系统提示它很可能漏掉最关键的约束条件。我在实践中会做一个粗略的 token 预算表。以 gpt-4o-mini 的 128k 上下文为例我不会把它当成真的能用到 128k。我一般只允许普通模式使用 8k-16k token 作为上下文上限。分配大概是系统提示 500-1000历史消息 3000-5000检索资料 3000-5000当前问题 200-500剩下留给生成。这个比例不是绝对黄金比例但它能保证每个部分都有足够空间同时不至于把模型注意力拉散。如果你的模式需要高频访问大量资料更好的做法是“先压缩再注入”。比如知识库问答检索到 10 篇文档不要全塞进去可以让模型先对 10 篇文档各生成一句话摘要摘要拼进上下文完整文档只在需要时才展开。虽然多了一次模型调用但对最终质量提升非常明显。4.3 召回不准问题在检索不在模式使用检索增强模式时如果模型回答“资料里没有相关信息”而不是“根据资料”先别急着调提示词。更常见的问题是检索器没有把真正有价值的片段找出来。我在项目里用 BM25它对关键词匹配敏感但对语义近义表达很弱。用户搜“退款流程”文档里写的是“退费步骤”BM25 可能就打不出高分的片段。解决方式有两种。简单方式是给检索环节加“查询改写”用大模型把用户口语问题改写成 2-3 个不同表达的核心 query再用这些 query 做检索。成本不高但对召回率提升明显。进阶方式是切到向量检索用 embedding 模型把文档和问题都转成向量再做相似度检索。向量检索对语义理解更强但需要额外部署 embedding 服务而且对关键词型 query 有时反而不如 BM25 精准。很多成熟系统会把两者做了混合召回再加一个重排阶段。你的场景如果刚起步先从关键词检索加查询改写做起就够了。4.4 上下文模式问题速查表我把这段时间遇到的高频问题整理成一张表方便你对照排查现象可能原因排查顺序与建议切换模式后输出仍是旧任务风格历史未清空或 system prompt 不够强先看切换后的 messages 日志再增强 system prompt输出回答引用了不存在的内容检索结果不可靠或上下文边界不清检查检索 top_k 和资料片段分隔方式同样的提示词结果忽好忽坏上下文里混入了过多无关历史减小 window_size或加入记忆摘要模型总是忽略输出格式要求输出约束被大量检索内容淹没把输出约束放到 system prompt 和最后一条 user 消息两次声明成本涨了但效果没涨上下文太长token 浪费严重按 4.2 的预算表做裁剪考虑压缩检索来源历史关键信息被窗口挤掉滑动窗口天然丢弃旧消息加入记忆摘要机制保留核心事实最后再说一个调试小技巧。如果你发现某个模式下模型怎么调都不对不要只改提示词。先把上下文完整打印出来自己假扮一秒钟模型问自己如果我是模型光凭这些信息我能正确回答吗如果作为人类看这段上下文都觉得信息不够、边界不清、提示不一致那么模型表现差几乎是必然的。上下文模式的本质就是替模型把“思考环境”收拾干净。环境干净了模型的能力才能真正落地。根据我个人经验上下文模式这东西没有一次就能调好的灵丹妙药。每加一个新场景就要重新审视一遍上下文结构和参数。但只要你把模式定义、窗口管理、检索注入、日志审计这四件事做到位后续的调整成本会越来越低。希望这套实践能帮你少踩一些我踩过的坑。
返回列表