ARTICLE DETAIL

资讯详情

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

上下文利用架构CUA:大模型上下文的预算分配与分块轮换实战

上下文利用架构CUA:大模型上下文的预算分配与分块轮换实战 不用多解释这套东西很多做AI应用的同学都在用。先说结论cua 不是某个官方库也不是某个框架的最新版本而是我在处理大语言模型长上下文任务时自己整理出来的一套上下文利用架构Context Utilization Architecture的缩写。简单说它解决的是“模型上下文窗口明明很大但用起来总觉得不够用、老是截断、老是让模型忘事”的尴尬问题。如果你的日常工作涉及把长文档喂给模型、做多轮对话系统、或者批量处理结构化数据这篇文章基本上就是为你准备的。我会把设计思路、核心代码、参数计算方式和踩坑记录全部放出来你可以直接抄作业。1. 整体设计与思路拆解为什么需要一套“上下文利用架构”先聊清楚背景。很多人以为把 prompt 加长、把上下文拉满就完事了但实际用起来根本不是这么回事。模型有最大上下文限制比如 128K tokens但你真的每次都能用完 128K 吗答案是不能而且就算能效果也不一定好。长上下文带来的是注意力漂移、中间信息被稀释、费用暴涨以及响应速度变慢。所以我当时立了个目标不追求把上下文塞满而是把有限的上下文空间用最合理的方式分配出去。1.1 核心需求解析这个方案解决什么问题cua 要解决的核心痛点有三个。第一个是无效占用问题。很多 prompt 里塞的都是重复的系统指令、长期不用的背景资料、历史对话的残留信息。这些内容既占空间又对当前回答没有直接帮助。需要有一种机制去动态裁剪和轮换。第二个是丢失关键信息问题。当上下文接近窗口上限时老的内容会被强制挤出但挤出的往往是最早放进来的、可能很重要的背景材料。模型“忘事”不是因为它笨而是因为你没给它留位置。第三个是成本失控问题。大模型的计费是按输入和输出 token 数来算的上下文越长每一轮问答的输入 token 就越多。如果多轮对话系统不做上下文管理聊到第 20 轮时光是把前面所有内容重新发给模型就够你喝一壶的了。cua 的设计目标就是在这三个问题上同时发力把上下文窗口从“一次性容器”变成“可持续运营的仓库”。1.2 方案选型背后的原因为什么不用现成方案其实市面上不是没有类似思路LangChain 里的 ConversationSummaryBufferMemory、LlamaIndex 里的 ChatMemoryBuffer 都干过类似的事。但我在实际体验后发现通用框架终归是通用框架它不知道你的业务场景里什么是关键信息。比如 LangChain 的 token 驱逐策略是 LRULeast Recently Used最旧的先踢出去。但在我的场景里最旧的那条往往是用户的任务说明踢了就完蛋了。所以我决定自己实现一套更可控的上下文利用架构核心就一句话每一段内容在上下文窗口里的去留由规则决定不由顺序决定。2. 核心细节解析与实操要点cua 的三大设计支柱这套架构看起来简单但要落地还是有几个关键设计需要拆开讲清楚。所谓“利用”并不是把窗口塞满而是精细化地运营它。cua 的三大设计支柱分别是分块策略、预算分配、以及续接轮换机制。2.1 分块策略如何把长文档切成“模型友好的小份”分块是所有上下文管理的基础。最常见的做法是按固定长度切块比如每 500 个 token 切一块但这样做很容易把句子、代码逻辑拦腰斩断。我的做法是按结构化边界优先、长度兜底。具体来说对于带标题的文档比如 Markdown 或者 HTML优先按标题层级切分对于代码文件按函数和类定义切分对于普通文本按段落和句子边界切分。只有当一个结构化块超过预设的最大长度时才会强制按 token 数切。这块代码可以用到一些现成的切分工具比如 LangChain 的 RecursiveCharacterTextSplitter它本质上是“优先按分隔符列表去切切不动再降级”这个思路非常符合我的需要。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size800, chunk_overlap100, separators[\n## , \n### , \n\n, \n, 。, ], ) chunks splitter.split_text(document)注意chunk_overlap这个参数我习惯设为 100 到 200 tokens作用是让相邻分块之间有一小段重叠这样模型在读取时不会因为边界切得太干净而丢失语境。它其实就是给两个分块之间加了一层“模糊地带”。2.2 预算分配把上下文窗口当成一笔钱来花上下文窗口是有限的资源所以一定要有预算表。我设计了一个简单的四级分配体系内容类型预算占比优先级说明系统指令5%最高定义角色、任务规则任何轮次都不能丢持久化背景20%高用户的核心任务描述、关键资料、长期记忆当前上下文35%中最近的对话轮次、当前正在处理的一段文档历史摘要40%可压缩旧对话先用摘要压缩摘要再满则丢弃这里的百分比不是拍脑袋定的是我在多个场景下测试后的经验值。系统指令的 5% 看起来少但足够装下一段 6K token 以内的角色设定和规则说明。持久化背景 20% 是大多数任务的“压舱石”这部分丢了整个任务就跑偏了。当前上下文 35% 用来放最近几轮的完整内容保证模型对“正在发生什么”有完整感知。历史摘要 40% 是我特意留出来的弹性空间因为摘要可以越压越短所以它可以充当整个系统的缓冲垫。预算分配的核心逻辑是优先级高的内容永远不被挤出优先级低的内容可以动态压缩甚至丢弃。2.3 续接机制当上下文真的满了怎么优雅地“忘事”总会有极端情况预算全部用完了但新内容还要进来。这时候不能粗暴地把最久远的记录丢掉需要走一套续接流程。这套流程我称为三阶降级法。第一阶段是压缩摘要把最早的那批会话记录用模型压缩成 3 到 5 句话的摘要存入历史摘要区。第二阶段是合并背景如果摘要区也满了就把持久化背景里的一些次要资料和摘要合并比如把同一主题的多条零散信息合并成一条。第三阶段是硬淘汰只有前两步做完还不行才会把最不重要的那条背景记录直接删除并在系统提醒里注明“以下信息已被移除xxx”让模型知道有内容缺失。为什么要做“注明”这一步而不是无声无息地删掉因为模型如果不知道信息被删了它可能会一本正经地编造自己“记得”的内容。明确告知后它至少会告诉用户“该信息不在当前上下文中”这能有效减少幻觉。3. 实操过程与核心环节实现从零跑通 cua 最小闭环理念讲得再多不如直接把代码跑起来。这一章我会带你从环境搭建开始实现一个可以直接用的 cua 最小闭环代码量不大但足够应对大部分个人项目和中小型应用的场景。3.1 环境准备与基础配置我的开发环境是 Python 3.10 以上版本主要的依赖库包括 openai或者你用的任意模型 SDK、langchain-text-splitters只要切分器不装全家桶、以及 pydantic 用于配置管理。不建议装完整的 langchain因为它的依赖链太长了而且很多功能我们根本用不到。pip install openai langchain-text-splitters pydantic配置文件我推荐用 YAML 或者环境变量来管理不要把 API Key 写死在代码里。下面是一份我常用的配置模板。model: name: gpt-4o-mini max_context_tokens: 128000 budget: system_ratio: 0.05 background_ratio: 0.20 current_ratio: 0.35 summary_ratio: 0.40 chunk: size: 800 overlap: 150注意不同的模型上下文窗口大小不同max_context_tokens一定要根据实际模型修改。另外实际使用时建议预留 10% 左右的 buffer不要把窗口算得死死的因为模型输出的 token 也要占空间。比如配置了 128K 窗口实际可用的输入上限我一般按 110K 左右来算。3.2 核心代码实现三句话讲完 cua 的主流程cua 的主流程说起来其实就三步第一步算预算。根据当前的max_context_tokens和配置的比例算出每个区能用多少 token。第二步分内容。把系统指令、背景资料、当前对话、历史摘要分别整理好按预算截断。第三步拼装请求。把四块内容按顺序拼成一个 prompt发给模型。下面这段是预算计算的实现。class ContextBudget: def __init__(self, max_tokens: int, config: dict): self.max_tokens int(max_tokens * 0.9) self.system_budget int(self.max_tokens * config[system_ratio]) self.background_budget int(self.max_tokens * config[background_ratio]) self.current_budget int(self.max_tokens * config[current_ratio]) self.summary_budget int(self.max_tokens * config[summary_ratio]) def assign(self, system_tokens, background_tokens, current_tokens, summary_tokens): return { system: min(system_tokens, self.system_budget), background: min(background_tokens, self.background_budget), current: min(current_tokens, self.current_budget), summary: min(summary_tokens, self.summary_budget), }在assign方法里我用min()做了一次截断。这个看起来很简单的操作配合上预算比例实际上就是整个 cua 的核心约束力。高阶玩法里可以在这里加入动态权重比如当 background 区的 token 不够用时可以临时借调 summary 区的额度但基础版先用固定比例就够了。接下来是分块和注入。这里我把分块后的文档片段按顺序塞进背景区只保留预算允许范围内的内容。def inject_background(self, chunks: list[str], budget: int, tokenizer) - tuple[list[str], str]: injected [] used 0 for chunk in chunks: chunk_token_count len(tokenizer(chunk)) if used chunk_token_count budget: break injected.append(chunk) used chunk_token_count dropped_summary self._summarize_dropped(chunks[len(injected):]) return injected, dropped_summary注意看这段代码里那个dropped_summary的返回。我没有直接扔掉装不下的分块而是把它们摘要成一段话放进 summary 区。这是 cua 和普通“截断式上下文管理”最大的区别装不下的内容不是简单地消失而是被降级压缩。再来说对话历史的轮换。多轮对话里不能把所有历史都原样塞进去否则聊到后面必然爆掉。我维护一个消息队列新消息从尾部加入当超过 current_budget 时把最早的消息整体移出并实时合入摘要区。摘要区的压缩函数可以单独调一次模型也可以用规则做关键词提取。为了省钱简单场景下我建议先用规则只有规则搞不定的复杂对话才调模型。3.3 完整调用示例一个可跑通的对话系统骨架下面是一个简单的对话函数骨架你可以直接复制到自己的项目里改。def chat_with_cua(user_input: str, history: list[dict], background_chunks: list[str], config: dict): budget ContextBudget(config[max_context_tokens], config) tokenizer lambda text: len(text) // 3 # 中文场景粗略估算英文字符可直接用len system_prompt config[system_prompt] # 1. 注入背景文档 bg, dropped inject_background(background_chunks, budget.background_budget, tokenizer) # 2. 整理历史超出的丢进摘要 current_history, summary rotate_history(history, budget.current_budget, tokenizer) messages [] if system_prompt: messages.append({role: system, content: system_prompt}) if summary: messages.append({role: system, content: f历史摘要{summary}}) if bg: messages.append({role: system, content: 参考资料 \n.join(bg)}) messages.extend(current_history) messages.append({role: user, content: user_input}) return call_model(messages)rotate_history的实现不展开细节核心思想是“按 token 预算反向弹出最旧消息弹出内容喂给摘要生成器”。建议你在实际使用中为每一轮对话后的消息队列做一个累计 token 数的缓存避免每轮都重新算一遍。4. 常见问题与排查技巧实录这些坑我替你先踩了说实话这套架构从理论到落地之间隔着一堆乱七八糟的意外。我在开发和使用 cua 的过程中遇到不少问题挑几个有代表性的分享给你。4.1 预算分配不合理导致的“答非所问”第一次跑通的时候我用的配置是 system 10%、background 10%、current 50%、summary 30%。结果在长文档问答场景里模型的回答质量一直上不去总是丢三落四。排查后发现background 的 10% 预算太少了一份 20000 token 的文档只能塞进 1000 多 token等于只看了个开头。后来我调整成 system 5%、background 20%、current 35%、summary 40%效果立刻改善。这给我一个教训预算比例必须根据具体任务动态调整。如果做的是长文档问答后台资料是核心background 占比可以调到 40% 甚至更高如果做的是多轮客服对话current 和 summary 才是重点。4.2 token 计算不准导致的超额截断有段时间我的系统总是出现“输出被截断”的问题排查到最后发现是 token 估算的问题。我当时用的 tokenizer 是按len(text) // 3估算的这个公式对中文还可以但对英文和代码就不太准了。中文字符在大多数模型中差不多是 1 个字符 0.6 到 1 个 token而英文单词平均是 1.3 个 token代码更是差异巨大。# 推荐的两个方案 # 方案一如果 API 是 openai 系的直接使用 tiktoken import tiktoken enc tiktoken.get_encoding(cl100k_base) token_count len(enc.encode(text)) # 方案二直接用模型的 tokenizer如果有的话 # from transformers import AutoTokenizer方案一更通用方案二更准确但依赖模型类型。我后面直接把所有 token 估算都换成了 tiktoken系统整体的稳定性上了一个台阶。这块一定要重视token 计算不准预算系统就是空中楼阁。4.3 模型“不听话”地重写系统指令有一次我发现即使系统指令区的占比只有 5%模型在回答时还是会偶尔“忘记”角色设定。排查发现是我把系统指令和其他内容全部拼接成一个 system message 导致的。模型很难从一大段拼接文本里区分哪些是不可违背的规则、哪些只是参考资料。解决办法是把 message 分成多个 system message分别承载系统指令、历史摘要和参考资料。在 OpenAI 的 API 里允许多条 system message 共存这就相当于给模型做了内容分区。实测下来角色遵守度明显提升。这个技巧简单但极其有效属于那种“你不会不知道但知道了会后悔没早用”的细节。4.4 摘要压缩失真导致的信息幻觉摘要区的设计初衷是好的但摘要本身也会“说错话”。早期我在做摘要压缩时直接让模型“总结上面几轮对话”结果模型偶尔会添油加醋把用户没说过的话也写进摘要里。后续对话基于这些错误摘要就会产生一次比一次离谱的幻觉。现在的做法是在摘要 prompt 里加一句硬性要求“只允许摘录原文明确出现过的信息禁止推测和补充。如果信息不确定写‘未提及’。”同时在摘要生成后加一个规则校验检查摘要里有没有原文中不存在的专有名词如果有就强制删除。虽然不能 100% 防止摘要失真但至少把幻觉的蔓延范围控制住了。常见问题现象解决思路预算分配失衡回答质量突然下降动态调整各区域占比测试几组比例再定输出被截断回答后半段消失换 tiktoken 精确计算 token预留 10% buffer角色遵守不严模型经常“跳出角色”拆分多个 system message 分区承载不同功能摘要失真对话内容逐步偏离事实摘要 prompt 加限制生成后做专有名词校验长文档开头信息丢失模型对前面内容没印象提高 background 占比或调整分块 overlap4.5 补充几个容易被忽略的小问题还有一个很常见的坑是分块的 overlap 设置不当。overlap 太少两个分块之间没有衔接感overlap 太多同一个信息被重复送入背景区浪费预算。建议 overlap 控制在 chunk_size 的 15% 到 25% 之间这是我在多个文档集上测试后的平衡点。另外如果你做的是流式输出应用注意在用户等待流式返回的过程中不要让新消息进入上下文队列否则用户看到的输出和实际上文对不上。我建议在流式输出期间加一个互斥锁等本轮输出结束后再处理下一轮输入。5. 这个方案后续还能怎么玩从最小闭环到进阶变形cua 的最小闭环只覆盖了单用户、单会话的场景。真实世界里你的用户可能是多租户的你的系统可能同时管理几百个会话每个会话都有自己的上下文预算。所以我把这个方案扩展了一些玩法。5.1 多会话内容隔离与统一预算池可以做一个上下文管理器每一个 session 单独持有一个ContextBudget实例。这样做的好处是不同会话之间完全隔离不会出现 A 会话把 B 会话的背景资料挤掉的情况。管理员可以根据用户等级或任务类型给不同的 session 分配不同的预算池。比如普通用户给 32K 上下文VIP 用户给 128K 上下文。只需要在创建 session 时传不同的 max_tokens 参数即可。5.2 结合 RAG筛选后再注入而不是全文注入在我的新版本里背景区不再直接注入全部分块而是先通过检索把最相关的 Top-K 分块筛出来再注入背景区。这等于在 cua 前面加了一个筛选器让珍贵的上下文空间只留给最相关的材料。def inject_background_with_retrieval(self, query: str, chunks: list[str], budget: int): # 用向量检索或关键词匹配选出最相关的 chunk reranked self.retriever.rerank(query, chunks, top_k10) return self.inject_background(reranked, budget, tokenizer)这种做法把 cua 从“容器管理工具”升级成了“智能上下文路由器”。对于知识库问答、企业文档检索这类场景效果提升非常明显。5.3 自动调参与自学习更进一步可以为 cua 加一个简单的自动调参模块根据每轮问答后用户的反馈点赞/点踩动态调整四个区域的预算比例。比如用户连续点踩某类问题说明当前上下文配比可能不适合这类任务系统就把 background 占比调高一些看看下轮效果是否改善。虽然这个“自学习”还比较粗糙但至少不需要人工介入调参了。我个人实测下来的体会是cua 的价值不在于某一个惊艳的算法而在于一套结构化的思维框架。它时刻在提醒我上下文是资源不是垃圾桶别什么都往里装。现在你在 Google 里搜“cua”大概率会看到一堆毫不相干的内容因为这个词在不同的领域的含义太多了。但只要你在做 AI 应用开发时想起这套上下文利用架构并且在项目里成功落地过一次你就能立刻理解我在说什么。最后再分享一个小技巧在你刚开始实践 cua 的时候先别急着写代码。拿出一张纸画一个正方形代表上下文窗口用不同的颜色标出你的系统指令、背景资料、当前对话和历史摘要然后想想哪个颜色占得太多、哪个颜色少得可怜。把这个图画清楚再做预算分配和代码实现你会比我当初少走很多弯路。
返回列表