ARTICLE DETAIL

资讯详情

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

context-mode:AI应用上下文管理与提示词组装实战

context-mode:AI应用上下文管理与提示词组装实战 1. 项目概述这个“context-mode”到底要解决什么问题1.1 为什么AI应用普遍需要一种“上下文模式”过去两年我接手的 AI 应用项目里至少有一半以上在“上下文处理”这个环节吃过亏。表面上大家用的是同一个模型但有人做出来的产品响应又准又能记住用户意图有人做出来的却像个健忘症患者每次问答都像第一次见面。差别不在模型本身而在上下文是怎么被组织、筛选和送进模型嘴巴里的。拿知识库问答来举例。用户问“帮我看看那份关于退货政策的文档里写了什么”如果你的应用只是把整份文档全部塞给模型轻则浪费 token重则因为超过上下文窗口直接被截断或者被一堆无关信息干扰判断。如果只塞一段开头又可能漏掉真正答案。context-mode 想做的就是给这种“该给模型看什么”的决策过程提供一个明确的模式在什么业务状态下用哪种上下文策略去取数、排列、生成提示词。这里的“模式”二字我理解成一套可切换的上下文处理策略。它不绑定具体算法而是一个框架工作栏、知识库、对话历史、用户画像、实时工具结果不同来源的信息按权重组合进上下文再根据当前任务类型自动选择组合方式。比如“问答模式”侧重知识库检索“对话模式”侧重历史记忆“任务执行模式”侧重工具调用结果。这就是 context-mode 这个项目名字的来历。1.2 context-mode 的核心定位与使用场景我在设计这个项目时的定位很朴素它不是一个单独部署的服务更像是一个嵌入在主业务逻辑里的上下文管理器向上对接应用层向下对接数据库和向量存储中间还能插入缓存和检索逻辑。适合的人群也很明确用 OpenAI 兼容接口或者各种国产大模型 API 做应用开发的工程师尤其是做智能客服、个人知识库、Agent 这类东西的团队。适用场景大概分成三类。第一类是长对话场景用户跟机器人聊了二十轮你不可能每次把二十轮全部原样丢给模型context-mode 负责把历史对话压缩成摘要加最近几轮原文的混合结构。第二类是知识问答场景系统需要动态从文档库里检索多个片段再把片段和问题按某种顺序拼装。第三类是 Agent 场景模型需要根据工具返回结果做下一步决策这时候上下文里必须保留工具调用的中间状态而不是只在最终结果里出现一句话。这三种场景看起来差别很大但落到工程上都离不开三件事选什么数据、怎么裁剪、怎么排序。接下来我会把整个项目的设计思路和实现细节拆开讲重点说清楚每一步为什么这么做以及我在实际写代码时踩过的坑。2. 整体设计拆解从系统架构到数据流2.1 上下文分层短期记忆、工作区、长期知识库第一版 context-mode 我犯过很典型的错误把所有信息罗列在一起塞进一个 Dict 再拼进 prompt。结果上下文越长模型越容易抓不住重点响应延迟也跟着涨。后来我把信息分成了三层这套分层基本成了后面所有改版的地基。第一层是短期记忆层保存当前会话最近几轮的用户输入、模型输出、意图标签和关键实体。它对应的是“用户刚刚说了什么”。第二层是工作区层保存任务执行过程中的临时状态比如 Agent 已经调用了哪些工具、生成了什么中间文件、还有哪些步骤没完成。第三层是长期知识层保存用户历史偏好、业务知识库里的相关内容、事实性的用户画像。这个分层本身不复杂但它决定了后面检索和裁剪的边界短期记忆层通常不检索直接读取长期知识层必须走检索不能全量灌入。数据流也按这个分层来走。用户请求进来之后context-mode 先把短期记忆层的原始对话按时间窗口取出再根据意图判断当前应该走“问答模式”还是“对话模式”接着才决定要不要触发长期知识层的检索。工作区是独立的只有模型真的在跑 Agent 任务时才把工作区内容合并进来平时不占上下文空间。2.2 context-mode 的状态机设计做状态机是我在这个项目里比较得意的一个决定。一开始想把“模式”做成一个可配置项后来发现没有状态的模式会导致一个问题模型在任务中途可能切换意图比如上一轮还在查天气这一轮突然问“那明天的会议呢”如果上下文模式不跟着变检索出来的东西大概率是错的。所以我给 context-mode 设计了一个轻量状态机状态包括 idle、query、summarizing、tool_calling 和 finalizing。当用户发来新消息状态从 idle 切到 query进入意图识别和检索。如果模型判断需要一个知识库片段作为依据就切到 summarizing先把检索到的多段内容归纳成一段简报。如果 Agent 需要额外信息才能继续就切到 tool_calling这时候上下文要保留工具调用历史而不仅仅是工具返回的最终值。模型每输出一轮状态都会重新评估这样模式切换不是靠人来手动指定而是靠状态机自动驱动。状态机的好处是让上下文组装有了明确分支逻辑。同一段历史对话在 query 状态下可能被压缩成“用户询问退款政策客服解释需要提供订单号”在 tool_calling 状态下则必须保留原始工具参数和返回值因为模型要拿着这些数据做下一步判断。这个区分在早期没有状态机时根本做不到。2.3 与主流LLM应用的集成方式context-mode 不能成为一个孤立系统否则接入成本高到没人愿意用。我把它设计成中间件的形式对外只暴露两个核心接口build(messages, context_id) 和 update(session_data)。应用层只需要在调用大模型 API 之前调用 build在收到新消息或工具结果后调用 update剩下的事情全部交给 context-mode 内部处理。这样集成的好处是可以无缝适配不同厂商的模型接口。因为 build 的返回值是标准 messages 数组OpenAI、Claude、通义千问、智谱这些接口都能直接吃。切换模型时不需要改上下文层代码只需要改模型适配器。另外我还留了一个 callback 钩子允许业务方在上下文构建完成后自己再追加一段 prompt比如系统指令、敏感词提示、品牌话术之类的。这个钩子看着简单但实际接第三方业务时非常常用。3. 核心实现细节让上下文真正被“用起来”3.1 上下文数据结构的定义与序列化第一版我直接用 Python 的 dict 和 list 保存上下文开发是快但坑也在后面当上下文需要持久化到 Redis 或数据库时嵌套结构非常难查、难更新。后来我定义了一套明确的 Pydantic 模型把 ContextChunk 作为最小单元。每个 ContextChunk 包含 chunk_id、source_type、content、score、priority、timestamp 和 expiry 这几个字段。source_type 可能是 dialogue、knowledge、tool_result、user_profile 或者 summary。score 是检索阶段留下的相关性分数priority 则是业务层指定的优先级比如用户主动提到的订单号priority 会强制提高。timestamp 和 expiry 用来做时间衰减避免过老的信息还在上下文里占位置。序列化用 JSON直接兼容 Redis 和向量数据库里的存储格式。这个设计让后续做过滤、排序有了统一的属性不用在业务代码里到处判断类型。3.2 上下文检索向量召回加关键词召回加重排context-mode 的检索部分我试过两种做法。第一种是只用向量检索把问题和文档片段都转成 embedding然后算余弦相似度。这种方法对语义相近但词面完全不同的内容效果好比如“如何申请退款”和“退货流程”能匹配上。但它的问题也很明显如果文档里包含具体订单号、SKU 编号这种专有名词向量检索经常找不到因为 embedding 模型对这些短标识符的表达非常不稳定。所以我在最终版本里用了混合检索。先用向量召回 top 50再用 BM25 关键词召回 top 50然后两路结果做融合去重最后用一个轻量重排模型打分。融合的方法不是简单相加而是做 RRFReciprocal Rank Fusion把两路结果的排名倒数相加。实际测试下来相比单纯向量检索混合检索在包含订单号、产品型号这类场景下的命中率提升了大概百分之三十。重排这一步不能省。两路检索合并之后至少有一二十个片段不可能全部塞进 prompt。我用的重排策略是优先保留与当前问题有时间相关性的片段其次是高优先级片段最后按相似度排序。重排之后只取 top 5 到 top 8基本能覆盖绝大多数问答需求。3.3 上下文的缓存与过期策略上下文构建很吃内存和计算尤其是混合检索那一层不可能每次请求都完整跑一遍。我的做法是引入两级缓存。第一级是内存缓存用 LRU 策略保存最近活跃的上下文块命中率大概能到七成。第二级是 Redis 缓存保存会话级别的上下文摘要和历史片段索引每次构建上下文时先从 Redis 加载基础素材只对新增内容做增量更新。过期策略上我踩过一个不小的坑。一开始把过期时间统一设成 24 小时结果用户前一天聊到一半的售后问题第二天继续问模型完全想不起来之前的情况体验非常割裂。后来我改成按 source_type 设置不同过期时间用户画像和长期偏好 30 天过期知识库片段 7 天过期短期对话记忆 24 小时过期工具调用结果 10 分钟过期。这样既控制 token 占用又不至于让用户觉得系统失忆。这里有个细节值得说一下过期的内容不是简单删除而是要倒进一个异步合并任务和旧摘要做一次融合再存回去否则跨天对话的摘要会丢失掉关键细节。3.4 提示词组装与 token 预算控制说句实话很多项目挂在上下文装配这一环不是因为没有检索而是因为不会算 token。我见过有团队把 8K 上下文的模型硬塞了 15K 的内容系统不报错但模型已经开始一本正经地胡说八道了。context-mode 在组装提示词前必须做 token 预算控制。我的实现里有一个简单的 token 计数器先估算当前 messages 的 token 数再按比例分配系统指令占百分之十五短期对话占百分之三十检索知识片段占百分之四十工具结果占百分之十五。如果总预算超了优先压缩短期对话把较旧的几轮合并成摘要而不是裁剪知识片段因为知识片段跟当前问题相关性最高。如果知识片段本身太多就进一步重排截断只保留分数最高的几段。组装顺序也是讲究的。我会把系统指令放在最前面然后是用户最新问题接着是知识片段最后才是历史对话。这个顺序和很多人的直觉不一样。实际测试中把高相关性内容放在离问题近的地方也就是 prompt 后半段模型对知识的利用率明显更高。历史对话放在最后因为它的作用是提供背景不是提供答案位置靠后一点反而不会干扰模型对知识片段的注意力。4. 实操过程一个可运行的 context-mode 最小闭环4.1 环境准备与项目布局这里我提供一个可以跑起来的最小项目方便你对照理解。先准备环境Python 3.10 以上安装 openai、pydantic、redis、sentence-transformers 和 rank-bm25。项目结构保持简单不用框架核心是几个模块context-mode-demo/ ├── models.py # Pydantic 数据模型 ├── retriever.py # 混合检索 ├── context_manager.py # 核心管理器 ├── prompt_builder.py # 提示词组装与 token 控制 └── app.py # 模拟业务入口我不建议一开始就把项目做成 FastAPI 服务因为上下文管理器的调试很吃日志你需要在本地把每一步中间产物打出来反复看。先跑通单机流程再包一层服务接口会顺手得多。4.2 实现核心类 ContextManagerContextManager 是核心类它负责整个上下文生命周期。我贴一个简化版本的关键代码from models import ContextChunk, SessionState from retriever import HybridRetriever from prompt_builder import build_messages class ContextManager: def __init__(self, redis_client, retriever: HybridRetriever): self.redis redis_client self.retriever retriever self.state SessionState.idle self.chunks: list[ContextChunk] [] async def build(self, user_input: str, context_id: str, session_data: dict): # 1. 从缓存加载历史上下文 cached self.redis.get(fctx:{context_id}) self.chunks self._deserialize_chunks(cached) if cached else [] # 2. 根据状态机决定处理模式 self.state self._decide_state(user_input, session_data) # 3. 混合检索知识库 if self.state in (SessionState.query, SessionState.summarizing): retrieved await self.retriever.retrieve(user_input, top_k20) self.chunks.extend(retrieved) # 4. 过滤过期和低分片段 self.chunks self._filter_and_rank(self.chunks, user_input) # 5. 构建标准 messages 并返回 messages build_messages(user_input, self.chunks, self.state) return messages async def update(self, context_id: str, user_input: str, model_output: str): # 把这一轮对话追加到上下文同时在后台更新摘要 self._append_dialogue(context_id, user_input, model_output) await self._refresh_summary(context_id) self._save_cache(context_id, self.chunks)这段代码最核心的决策在_decide_state它决定当前会话应该走哪个上下文策略。我的实现里判断顺序是如果 session_data 里存在 pending_tool_calls就进入 tool_calling 状态如果用户输入包含知识类关键词并且有对应知识库索引就进入 query 状态否则保持普通对话状态。这个规则看着简单但足够覆盖大多数真实场景复杂意图判断可以后面再接一个意图分类模型。4.3 接入 OpenAI 兼容接口的完整流程有了 ContextManager 之后接入模型接口就很简单了。下面是一个 FastAPI 的 POST /chat 接口示例from fastapi import FastAPI from context_manager import ContextManager app FastAPI() manager ContextManager(redis_client, retriever) app.post(/chat) async def chat(user_id: str, session_id: str, content: str): context_id f{user_id}:{session_id} # 先用 context-mode 构建上下文 messages await manager.build(content, context_id, session_data{ pending_tool_calls: tool_agent.pending_calls, }) # 调用大模型 from openai import AsyncOpenAI client AsyncOpenAI() resp await client.chat.completions.create( modelgpt-4o-mini, messagesmessages, temperature0.3, ) # 更新上下文 await manager.update(context_id, content, resp.choices[0].message.content) return {reply: resp.choices[0].message.content}这套流程跑通之后你可以在日志里直接看到 build 返回的 messages 结构。我最常用的是打印每条 message 的 role 和 content 长度再对比模型最终回答的质量。如果发现回答偏了第一件事不是调模型参数而是回去看 messages 里知识片段是不是被截断了、历史对话是不是占了太大比例。4.4 效果对比开与不开 context-mode 的差异我在一个测试数据集上做过一个很粗糙的对比实验数据是三万字的产品手册加五十条用户咨询。不开 context-mode直接把最近十轮对话和整本手册塞进 prompt模型回答准确率只有四成左右而且响应时长平均超过五秒。开了 context-mode 之后同样的问题prompt 里的 token 数减少了大约七成回答准确率提升到七成五以上响应时长也降到了两秒以内。这个对比不严谨但足以说明问题。减少 token 不只是省钱它直接影响模型对关键信息的注意力。很多团队抱怨“模型能力不够”其实有相当一部分情况是上下文里塞了太多噪音。context-mode 不是让你的模型变聪明而是让模型把聪明用在真正该看的内容上。5. 常见问题与排查技巧实录5.1 token 限制与上下文溢出的处理我在上线后遇到的第一个线上事故就是 token 溢出。用户连续聊天超过三十轮历史对话的原始文本加起来已经突破模型的上下文窗口接口直接报错。后来我加了一个兜底逻辑build 之前先统计 messages 总 token一旦超过安全阈值就对早期对话做折叠摘要最多只保留最近五轮原文。这里有个容易忽略的细节折叠摘要不能每一轮都做否则会浪费大量模型调用。我采取的策略是当历史对话超过十轮时才触发摘要压缩压缩时一次性把旧对话合并成两三条摘要并把原文本移到 Redis 里的历史归档区。这样在短期内既能保留关键信息又不会反复消耗 token。5.2 检索结果不相关、召回率低的排查如果你发现模型回答里带不出知识库内容首先检查检索模块的召回结果。我在 retriever 里加了一个 debug 开关可以打印出每个检索片段的 score 和 source_type。最常见的问题有三个一是 embedding 模型和文档语言不匹配比如文档是中文默认 embedding 模型却是英文优化的二是索引切分粒度过大长文档被切成一整块检索相关性被稀释三是重排阶段把本来高分的片段给过滤掉了。我调试时习惯直接看融合后的 top 8 列表。如果 top 8 里没有正确答案问题一定在检索源头不是 prompt 的问题。如果 top 8 里有正确答案但模型没用上问题就出在提示词组装顺序或 token 裁剪上。这两种问题排查路径完全不同千万别混在一起调。5.3 缓存一致性用户数据更新后旧上下文还在缓存是很多团队在上下文系统上翻车的高发区。最常见的现象是用户改了自己的收货地址但一问智能客服模型还在用旧地址回答。根因很简单Redis 缓存里的用户画像没有跟着业务数据一起更新。我的解决方案是给 ContextChunk 里的 user_profile 类型加上版本号字段。业务数据每次变更时不只更新主数据库还要把 context 缓存里对应的 chunk 版本号加一。检索时如果发现版本号不匹配直接丢弃缓存里的 chunk重新从主库读取最新画像。这个版本号机制看起来土但比什么缓存失效策略都可靠。5.4 并发场景下的上下文隔离问题如果你的应用是多用户并发访问一定要小心上下文串场。我早期犯过一个低级错误把 context 对象存在了一个全局变量里结果用户 A 的问题被用户 B 的历史对话污染了模型回复里出现毫不相干的个人信息。排查了很久才定位到问题。后来我强制所有上下文对象都从 context_id 派生每个请求进来先按 context_id 从 Redis 加载自己的独立 chunk 列表构建完成后再写回 Redis。会话数据永远不落到全局变量。这个约束写进代码 review 清单之后就再没出现过串场问题。如果你用异步框架还要额外小心 await 之间不能共享可变状态不然并发协程会交叉修改同一个列表。6. 实战经验与后续扩展6.1 我在实际项目中踩过的坑做 context-mode 这一路印象最深的坑不是技术难点而是我一开始把需求想复杂了。最初版本我做了很重的意图分类模块想通过模型判断用户问题类型再选择检索策略结果每次请求都增加一轮模型调用延迟飙升而且意图分类偶尔出错反而把整个上下文构建带偏了。后来我把这层换成规则加状态机效率更高系统更稳定。还有一个教训是日志必须足够细致。上下文系统的中间状态非常多哪些 chunk 被召回、哪些被天然截断、summary 怎么合并的、token 分配比例是多少这些信息如果不打印出来出了问题就只能瞎猜。我在 ContextManager 里加了结构化 JSON 日志每次 build 都会输出一段完整记录线上排查效率提升了一个量级。6.2 这个项目还可以往哪些方向延伸context-mode 目前的版本已经很稳定但还有几个方向值得继续做。第一个方向是接入多模型路由不同复杂度的问题自动选择不同模型这能进一步降低成本和延迟。第二个方向是自动评估上下文质量用一个小模型对每次回答做相关性打分把低分样本收集起来反哺检索权重。第三个方向是做流式上下文更新模型输出过程中就同步把关键信息写回状态而不是等整轮回答结束才处理。如果你打算基于这个项目做二次开发我建议先从日志和监控入手把上下文构建过程可视化。只有看清每一步发生了什么你才能真正调出一个贴合自己业务场景的 context-mode。
返回列表