你有没有遇到过这种情况:同一个 AI 助手,在某些页面里回答得很专业,换到另一个页面就怪话连篇。我去年接手了一个在线售后系统,用户反馈最多的就是“AI 答非所问”。当时我们所有用户问题都走同一个对话框,模型拿到的只有用户最后一句话,历史记录、当前订单页信息、售后规则一概没有。后来我把项目里的提示词工程按 context-mode 的思路重构,把上下文拆成三层,配合可开关的模式管理,答非所问的比例很快降到了可接受范围。
context-mode 这个词看着像某个框架的专有配置,实际上就是“上下文模式”的英文写法,我更喜欢把它理解成一组给大模型准备提示词的选择规则:什么信息该进、什么不该进、按什么顺序进、最多进多少。它适合两类人,一是想把大模型真正放进产品里的开发,二是经常被“AI 输出不受控”折磨的产品经理。这篇文章用我实际跑通的一套做法讲清楚 context-mode 的三种常见形态、具体实现和一个可复用的脚手架,内容偏工程落地,没有复杂的公式,读完了可以直接照着改。
1. 拆开来看:context-mode 其实在管“模型能看见什么”
1.1 一个容易被忽略的常识:大模型根本没有长期记忆
很多人觉得 AI 助手好像记得之前聊过什么,这是产品层做了缓存,语言模型本身是无状态的。每一次请求,都要把角色设定、对话历史、当前问题重新拼装成一个 messages 数组再发出去。所谓 context-mode,就是决定这个 messages 数组由哪些片段组合而成的规则。
我用一个临时工来打比方。今天请你来回答客户问题,如果你手里只有客户当下说的这句话,你只能靠猜;如果我把整个公司的资料都丢给你,你又要在一堆无关文档里翻半天。最理想的情况是你在开口前先被告知:现在来的是一个投诉客户,他刚才提过订单号,你只需要看这张订单表。context-mode 干的就是这个“开口前判断该看哪几份材料”的过程。
理解了这个前提,你就明白为什么同样一个模型,在不同产品里表现能差出几个量级。不是模型变了,是它每次开口前看到的材料不同。很多团队吐槽大模型不好用,其实是在上下文组织这件事上偷了懒。
1.2 三层上下文和它们的优先级
我在项目里把上下文分成三层,也是很多 context-mode 设计里最常用的分法:
- 全局上下文(globalContext):公司固定信息,比如品牌话术、售后政策、开放时间,基本不随用户变化。
- 会话上下文(userContext):当前用户身份、订单信息、历史对话摘要,跟某个具体用户绑在一起。
- 活动上下文(activeContext):当前页面焦点、正在操作的内容、选中字段,比如用户正在看的订单页和点击的按钮。
最初我把这三层全部打开,效果反而很差。原因也简单,模型手上的材料太杂,它分不清哪个信息对当下这个问题最关键。后来我给上下文加了优先级,排序是活动上下文大于会话上下文,会话上下文大于全局上下文。顺序是靠经验试出来的:离用户当下动作越近的信息,越应该放在靠近问题的地方,这样模型更容易把它作为判断依据。这在很多对话模型里都有体现,它不是玄学,而是注意力分布带来的近因偏置。
1.3 为什么叫“模式”,而不是“永远开启”
既然上下文有用,为什么不把所有信息永久注入?因为成本不是线性的。
第一是 token 成本。模型按输入输出计费,多带 2000 个 token,费用就是实打实多出来的。第二是延迟。输入越长,首字返回时间越慢,在线客服场景尤其明显。第三是信息干扰。无关上下文不仅浪费预算,还会把回答带偏。所以 context-mode 的本质是一个可插拔的开关装置,不同的业务入口打开不同的模式组合,而不是一个全局开关。
2. 三类最常见的 context-mode 形态与选型
2.1 会话记忆模式:让对话能“接着聊”
第一种最常见,也最好理解,就是决定要不要把历史消息传给模型。用户第一轮说“我耳机坏了,想退货”,第二轮说“订单号是 12345”,如果你不带上第一轮,模型根本不知道这个订单号是用来干嘛的。
会话记忆模式下面还能继续分,我直接整理成一张表:
| 模式名称 | 核心逻辑 | 适合场景 | 成本特点 |
|---|---|---|---|
| 单发模式(single-shot) | 每次只带当前问题 | 一次性咨询、工具执行 | 最低,但多轮连续性差 |
| 滑动窗口模式(window) | 拼接最近 N 轮对话 | 普通客服、产品助手 | 中等,实现简单 |
| 摘要记忆模式(summary) | 把早前对话压缩成摘要 | 长文档写作、连续任务 | 需要额外模型调用,但有上限 |
选哪一种,主要看业务是否需要“记得住但不被拖累”。普通售后客服用滑动窗口,保留最近 6 到 8 轮就够;如果用户在做一个需要连续多轮确认的任务,比如收集需求再生成方案,那就要用摘要模式,因为无限累积的窗口迟早会把 token 撑爆。
这里要特别说一句,摘要模式不是把历史消息直接拼上去,而是每隔几轮让模型把前面的内容提炼成结构化摘要,再作为一条上下文重新注入。代价是多一次模型调用,换来的是可控的长任务记忆。
2.2 工作区感知模式:让 AI 看见你正在处理的东西
第二种模式在 IDE 插件、在线文档、代码评审工具里很常见。它的作用是让模型知道用户当前在看什么文件、选了哪段代码、正在哪个页面操作。这一类问题里最大的坑是“贪多”。
我做过一个代码助手,最初想省事,把整个仓库的文件内容都塞进上下文,结果模型回答问题时非常慢,而且经常被仓库里无关的历史代码带偏。后来我把工作区感知改成以下几个规则:
- 只携带当前焦点文件的内容;
- 用户有选中代码时,优先携带选中部分和它所在函数头部的定义;
- 额外附带一份轻量目录树,让模型知道文件在项目里的位置;
- 其他文件不自动加载,等模型确实需要时再按需检索。
这个思路本质上是把“上下文模式”和“按需搜索”分开。上下文模式负责把高概率相关的信息预先放入,搜索工具负责处理那些低概率但需要的边缘情况。两者配合,才能兼顾质量和成本。
2.3 角色预设模式:先让模型知道你是谁、别越界
第三种模式是最容易被忽略、但对输出风格影响最大的一种:角色上下文。它是把产品想表达的回答视角、边界和语气放在 system prompt 位置,让模型在生成前就有一个明确框架。
比如在售后场景里,角色预设可能是这样:“你是某电商平台的售后客服助手,回答必须基于已提供的订单数据和知识库,信息不足以支持判断时,要明确告诉用户无法确认,不能自己推测。”这句话单独看很短,但它是整个 context-mode 里最稳定的部分,因为每次请求都会被注入。
有一点需要注意,角色预设应该放在 system 消息里,而不是夹在一堆用户历史消息中。单独放的遵循度更高,也方便运营团队独立修改。我之前见过一个项目,把角色预设埋在几千字的历史记录里,结果产品想调整话术,开发还得从对话缓存里翻字符串,非常痛苦。
3. 实操:从零搭一个可复用的 context-mode 脚手架
理论讲多了容易飘,这部分直接给代码和配置。下面是一套我用了很久的最小上下文构建器,它的核心思路是分层次塞入信息,再按优先级合并,最后做 token 预算控制。
3.1 上下文来源的三层设计
先明确每一层负责什么数据,这个设计在后面接权限过滤和开关控制时会很顺手:
- globalContext:全局配置,比如品牌名、售后政策、默认语气,适合放在 system 位置。
- userContext:当前用户相关,比如用户等级、最近订单、历史会话摘要,需要带权限标签。
- activeContext:当下正在操作的内容,比如选中的代码、正在编辑的文档段落,通常优先级最高。
每一层在代码里都可以是一个独立的 context layer,由外部模块决定是否注入。这样做的好处是,不同业务入口可以灵活组合,比如 A 入口只开 globalContext 和 userContext,B 入口三个全开。
3.2 核心代码:ContextBuilder
下面是核心实现,我用 TypeScript 写的,改成其他语言也很容易。
type ContextLayer = { name: string; priority: number; content: string; permission?: string[]; }; class ContextBuilder { private layers: ContextLayer[] = []; add(layer: ContextLayer) { this.layers.push(layer); return this; } build(maxTokens: number): string { const sorted = this.layers.sort((a, b) => a.priority - b.priority); let used = 0; const parts: string[] = []; for (const layer of sorted) { const cost = estimateTokens(layer.content); if (used + cost > maxTokens) { parts.push(trimByTokens(layer.content, maxTokens - used)); break; } parts.push(layer.content); used += cost; } return parts.join('\n'); } }这段代码最关键的一点是,每一层上下文之间是独立追加的,超出预算时优先保留优先级高的层,优先级低的层会被裁剪而不是一起丢失。实际使用中,estimateTokens 和 trimByTokens 最好用模型自带的 tokenizer 来实现,不要用字符串 length 截断,中文场景下字符串长度和 token 数不是一回事。
在把上下文交给模型前,还需要把它拼到 messages 数组里,这步也可以做成一个统一入口:
const context = builder.build(maxContextTokens); const messages = [ { role: 'system', content: systemLayer }, ...history.slice(-windowSize), { role: 'user', content: `${context}\n\n用户当前问题:${question}` } ];我建议把上下文和用户问题之间用空行隔开,让模型能明显区分“背景信息”和“当前诉求”。有些时候模型会把夹在历史对话里的上下文误认为用户新发的消息,显式分隔能有效避免这一类误解。
3.3 参数表与调优组合
参数不是越多越好,我这里整理了几个最常用的配置项,以及我习惯的默认值:
| 参数 | 建议默认值 | 说明 |
|---|---|---|
| max_context_tokens | 模型上限的四分之一 | 给模型生成回答留出空间 |
| auto_trim | true | 超出预算时自动裁剪而不是报错 |
| lazy_loading | true | 活动上下文按需加载,不全量注入 |
| session_ttl | 1800 秒 | 超过半小时没有新消息,清空滚动上下文 |
| permission_filter | true | 在 build 之前过滤无权限的上下文片段 |
把四个开关做成独立配置,让产品团队可以按业务入口开合,是我比较推荐的做法。比如页面客服开启会话记忆和活动上下文,纯表单提交工具则全部关闭,只保留必要的全局上下文。这样每个入口的 token 消耗和回答风格都能被单独控制。
4. 把 context-mode 放进真实项目,最常见的五个坑
4.1 只要为了“效果好”就无脑塞全部
这个坑我踩过,很多团队也踩过。刚开始做 AI 功能时,总觉得信息给得越多模型越聪明,于是把整个文件、整段历史、所有字段一股脑传进去。结果模型开始从无关内容里“找线索”,回答看起来有条理,实际是编出来的。
后面我给自己定了一条规则:每次新增一层上下文,先用 A/B 测试看它到底有没有提升回答准确率。没有提升就直接去掉。上下文不是越多越好,而是越准越好。你不用证明某条信息有它更好,你需要证明的是它带来的收益超过它引入的噪音和成本。
4.2 旧对话话题污染新任务
会话上下文有一个很头疼的问题,就是用户聊着聊着突然换了任务。比如用户先问售后政策,然后又发了一段报错日志让解释,如果你的上下文还是售后知识库,模型大概率会把日志往售后方向上解读,答非所问。
我现在的处理方式是给会话加一个主题标记。上下文模式里保存的不只是对话历史,还保存一个当前主题域。当新的用户消息与主题域的相似度很低时,就触发一次会话重置,只保留角色预设和全局上下文,清空历史滚动摘要。虽然这个机制没法做到完美,但能显著减少跨任务干扰。
4.3 裁剪函数用字符串 length,导致内容断裂
这类 bug 很隐蔽。很多人在做 token 预算裁剪时,直接用字符串 slice 截断,中文句子经常被截出半个词,模型看到不完整的片段,输出就会出现奇怪的字符或者前言不搭后语。
正确的做法是使用与模型匹配的 tokenizer 来计算和裁剪。token 是模型处理文本的单位,它不是简单的字符长度。有些中文一个字就可能占多个 token,直接用字符数去凑整数,最终得到的 token 数一定会偏差。我的建议是在有 tokenizer 可用时尽量用它,同时保留一个 token 计数测试,每次调整 prompt 模板后跑一遍,确保裁剪结果不会把关键字段切断。
4.4 上下文内容更新不及时:改完代码 AI 还拿旧版本回答
在开发工具类产品时,这类问题特别明显。用户刚保存了一个文件,然后问 AI 新逻辑怎么改,结果模型的回答还基于旧代码,因为工作区上下文用的是缓存版本。如果缓存清理逻辑不到位,就会出现这种“改了等于没改”的体验。
解决方案是给焦点文件加变更监听,文件保存或内容变化时,立即把对应的缓存上下文标记为失效。我个人的倾向是活动上下文里不做缓存,每次构建 prompt 时都读取真实文件内容,换来的是准确度。如果性能实在紧张,可以把缓存时间控制在几百毫秒级别,而不是读文件时永久复用。
4.5 权限标签没有执行,过滤只写在提示词里
这是最值得重视的一点。很多 context-mode 会带上用户数据,比如订单、收藏、个人信息。如果在拼接 prompt 时没有做权限过滤,等于把这些数据当成普通文本交给了模型。模型一旦在回答中复述出来,或者被用户用提示词诱导出来,就是一次实际的数据泄漏。
我的做法是给每一层上下文打权限标签,在 build 之前执行 permission_filter。过滤动作发生在数据进入 prompt 之前,而不是靠“请勿泄露”这类提示词约束模型。权限边界比上下文丰富度重要得多,这一点怎么强调都不过分。
最后分享一个小习惯。每新增一种 context-mode,我会先在配置表里写下两行:这一层想解决什么问题,哪些内容绝对不能进入这一层。把这两个问题想清楚,再决定要不要把它加进 builder。踩过几次坑之后,我发现好的上下文设计不是靠灵感,而是靠这种能明确回答“为什么它在这里”的克制。