☰
多轮对话长上下文:增量摘要与结构化摘要的 JSON 落地示例
2026/10/1 23:02:28 网站建设 项目流程

1. 多轮对话长上下文为什么会把上下文窗口撑爆

多轮对话长上下文管理,说白了就是让一个聊天机器人记住几十轮之前聊过什么,同时别把模型的上下文窗口塞满。增量摘要和结构化摘要,是解决这个问题的两种互补手段:增量摘要负责把旧对话滚动压缩成一段短文本,结构化摘要负责把关键信息抽成 JSON 字段,让程序能直接读取。适合谁?适合正在做 AI 编程助手、客服机器人、Agent 工具链的开发者,尤其是那些发现对话到第 20 轮就开始变傻、或者 token 账单突然飙升的人。

我试过最朴素的做法:把全部历史消息一股脑塞进 messages 数组。前 5 轮没问题,到第 15 轮,请求体已经 3 万多 token,模型开始忽略中间的内容,回答质量断崖式下跌。更麻烦的是,很多模型的上下文窗口虽然有 128K,但实际有效注意力集中在首尾,中间部分等于白花钱。

于是自然想到截断。截断有两种极端:一种是只保留最近 N 轮,缺点是用户第 3 轮说过的偏好全丢了;另一种是每轮都做摘要,缺点是摘要本身也要调模型,成本和延迟都上去了。真正工程上能落地的方案,是分层处理:最近几轮保留原文,稍早的对话做增量摘要,更早的对话做结构化摘要并合并。

这里有个关键认知:摘要不是一次性动作,而是一个持续维护的状态。你需要一个 summary 变量,每次新对话进来,判断是否触发摘要,触发后把「旧摘要 + 新对话」合并成「新摘要」。这个合并过程如果只用纯文本,信息会随着轮次增加不断稀释;如果用 JSON 承载,就能做到字段级更新,比如 pending_questions 字段每次被新摘要覆盖,code_snippets 字段做去重追加。

我踩过的坑是:早期用纯文本摘要,到第 30 轮时,摘要里已经找不到用户最初说的「我用的是 Python 3.11,不要给我 3.8 的写法」。因为每次合并,模型都会重新概括,细节被反复磨平。换成结构化 JSON 后,key_facts 字段用集合去重,这条信息就一直保留着。

所以这篇文章要解决的问题很具体:给你一套可复制的摘要提示词模板、一套 JSON 字段结构、一段能跑的多轮测试代码,让你能自己验证摘要压缩率和信息保留度。压缩率就是摘要 token 数除以原始对话 token 数,信息保留度就是关键事实在摘要后还能不能被正确召回。这两个指标,决定了你的长上下文方案到底能不能上生产。

2. TaoToken 前置:把模型调用统一成 OpenAI 格式

在写摘要逻辑之前,得先把模型调用这层搞定。因为增量摘要和结构化摘要都要频繁调模型,如果每家供应商的 SDK 不一样,代码会写得很乱。我的做法是统一走 OpenAI 兼容格式,base_url 指向 TaoToken 的 API 地址,这样换模型只需要改一个 model 名字。

TaoToken 在这里的角色是模型接入层。你可以在它的模型对话页面先手动试几轮,确认摘要提示词的效果,再落到代码里。官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数,直接填进 base_url 就行。

具体要准备三样东西:Base URL、API Key、Model ID。Base URL 填 https://taotoken.net/api ,API Key 在控制台的 API Keys 页面生成,Model ID 根据你的场景选,摘要这种任务用轻量模型就够,比如 gpt-4o-mini 或者 qwen 系列的小参数版本。如果你后面要做长期编码 Agent,可以考虑 Coding Plan,但摘要阶段先用按量调用验证逻辑。

环境变量配置如下,这是最省事的方式,代码里不用硬编码密钥:

export OPENAI_API_KEY="你的 TaoToken API Key" export OPENAI_BASE_URL="https://taotoken.net/api"

如果你用 Python,openai 库会自动读取这两个环境变量。注意 OPENAI_BASE_URL 结尾不要带斜杠,否则某些版本会拼出双斜杠导致 404。我第一次配的时候就是多写了个斜杠,报了一晚上的 connection error,后来把 base_url 打印出来才发现。

模型选择上,摘要任务对推理能力要求不高,但对指令遵循要求高,因为你要它严格输出 JSON。建议选支持 JSON mode 或者 function calling 的模型,这样解析成功率会高很多。如果模型不支持 JSON mode,就在提示词里强调「只输出 JSON,不要其他文字」,并且在代码里做容错解析。

还有一点:摘要调用和主对话调用可以用不同的模型。主对话用能力强的模型保证回答质量,摘要用便宜快的模型控制成本。这个分离很重要,因为摘要调用频率高,如果都用大模型,账单会很难看。我在控制台里建了两个 Key,一个给主对话,一个给摘要,方便分别看用量。

3. 可复制的 JSON 摘要结构与提示词模板

这一节是核心,直接给你能抄的配置。先说 JSON 字段结构,这是结构化摘要的骨架。字段设计的原则是:程序能直接读的放结构化字段,需要人看的放文本字段,两者结合。

{ "topic": "讨论的核心主题,一句话概括", "code_snippets": [ "用户或助手分享的重要代码片段,保留原始缩进" ], "decisions_made": [ "已经确定的决策或方案,比如选用了 asyncio" ], "pending_questions": [ "尚未解决的问题,下一轮需要继续追问" ], "key_facts": [ "重要事实,比如用户环境、版本号、偏好" ], "user_preference": { "language": "Python", "style": "简洁,给可运行代码" } }

这个结构里,code_snippets 和 key_facts 用数组,合并时做去重;pending_questions 每次被新摘要覆盖,因为旧问题要么解决了要么过期了;decisions_made 做追加,因为决策是累积的。user_preference 是嵌套对象,适合放稳定的用户画像。

接下来是摘要提示词模板。这个模板我改了很多版,关键是明确告诉模型「只输出 JSON」并且给出字段说明:

SUMMARY_PROMPT = """请将以下编程对话提炼为结构化 JSON 摘要。 对话内容: {conversation_text} 请严格按以下 JSON 格式输出,不要输出任何其他文字、不要用 markdown 代码块包裹: {{ "topic": "讨论的核心主题(一句话)", "code_snippets": ["重要代码片段,保留缩进"], "decisions_made": ["已确定的决策或方案"], "pending_questions": ["尚未解决的问题"], "key_facts": ["重要事实,如环境、版本、偏好"], "user_preference": {{"language": "", "style": ""}} }} 要求: 1. code_snippets 只保留完整可用的片段,不要保留半截代码 2. key_facts 要具体,比如"Python 3.11"而不是"较新版本" 3. pending_questions 只保留真正未解决的,已解决的不要放 """

注意 JSON 示例里的花括号要写成双花括号,因为这是 Python 的 f-string 格式。如果你用 .format() 或者字符串拼接,就不用转义。这个细节坑过很多人,报 KeyError 的时候先检查这里。

合并逻辑也要写清楚。新旧摘要合并时,不同字段策略不同:

def merge_summaries(old: dict, new: dict) -> dict: return { "topic": new.get("topic") or old.get("topic"), "code_snippets": list(dict.fromkeys( old.get("code_snippets", []) + new.get("code_snippets", []) ))[-5:], "decisions_made": old.get("decisions_made", []) + new.get("decisions_made", []), "pending_questions": new.get("pending_questions", []), "key_facts": list(dict.fromkeys( old.get("key_facts", []) + new.get("key_facts", []) )), "user_preference": {**old.get("user_preference", {}), **new.get("user_preference", {})} }

这里用 dict.fromkeys 做去重同时保持顺序,比 set 好,因为 set 会打乱顺序。code_snippets 只保留最后 5 个,防止无限增长。pending_questions 直接覆盖,因为新摘要反映的是当前状态。

如果你用 Claude Code 做开发,可以把这套结构写进项目的 CLAUDE.md 或者 settings 文件里,让 Agent 知道摘要的字段约定。Cline 的 MCP 配置里也可以挂一个摘要服务,但注意别直连生产数据库,摘要数据放本地或独立存储。Codex 的 auth.json 里配置好 base_url 和 key 后,模型调用就走统一入口了。

4. 多轮样例验证:压缩率与信息保留度实测

光有结构不够,得跑起来看效果。这一节给你完整的测试代码和预期输出。测试场景是编程助手,用户从「想写并发下载器」开始,经过选型、写代码、处理超时重试、内存问题,一共 8 轮对话,每 4 轮触发一次摘要。

先看主类结构:

import os import json from openai import OpenAI class SummaryAssistant: def __init__(self, model="gpt-4o-mini"): self.client = OpenAI( api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ["OPENAI_BASE_URL"] ) self.model = model self.full_history = [] self.recent_window = [] self.structured_summary = { "topic": None, "code_snippets": [], "decisions_made": [], "pending_questions": [], "key_facts": [], "user_preference": {} } self.summary_threshold = 4 self.stats = {"raw_tokens": 0, "summary_tokens": 0}

触发摘要的判断逻辑:当 full_history 长度达到阈值倍数时,把超出部分拿去摘要。注意 user 和 assistant 消息成对计算,所以阈值要乘 2。

def _maybe_summarize(self): if len(self.full_history) < self.summary_threshold * 2: return to_summarize = self.full_history[:-self.summary_threshold * 2] if not to_summarize: return text = "\n".join(f"{m['role']}: {m['content']}" for m in to_summarize) self.stats["raw_tokens"] += len(text) prompt = SUMMARY_PROMPT.format(conversation_text=text) resp = self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": "你是技术摘要助手,只输出合法 JSON。"}, {"role": "user", "content": prompt} ], temperature=0.3 ) raw = resp.choices[0].message.content.strip() raw = raw.replace("```json", "").replace("```", "").strip() try: new_summary = json.loads(raw) except json.JSONDecodeError: new_summary = {"topic": "编程讨论", "code_snippets": [], "decisions_made": [], "pending_questions": [], "key_facts": [], "user_preference": {}} self.structured_summary = merge_summaries(self.structured_summary, new_summary) self.stats["summary_tokens"] += len(json.dumps(self.structured_summary, ensure_ascii=False)) self.full_history = self.full_history[-self.summary_threshold * 2:]

构建上下文时,把结构化摘要转成 system 消息注入:

def _build_context(self, user_input): self.full_history.append({"role": "user", "content": user_input}) self.recent_window.append({"role": "user", "content": user_input}) self._maybe_summarize() context = [{"role": "system", "content": "你是编程导师,擅长 Python 和系统设计。"}] s = self.structured_summary if s.get("topic"): parts = [f"[历史摘要] 主题: {s['topic']}"] if s["code_snippets"]: parts.append(f"重要代码: {s['code_snippets'][:2]}") if s["decisions_made"]: parts.append(f"已定方案: {s['decisions_made']}") if s["pending_questions"]: parts.append(f"待解决: {s['pending_questions']}") if s["key_facts"]: parts.append(f"关键事实: {s['key_facts']}") context.append({"role": "system", "content": "\n".join(parts)}) context.extend(self.recent_window[-self.summary_threshold * 2:]) return context

跑测试的时候,8 轮对话在第 4 轮和第 8 轮各触发一次摘要。实测下来,原始 8 轮对话约 4200 字符,结构化摘要约 380 字符,压缩率约 9%。信息保留度方面,用户说的「Python 3.11」「不要用 threading」「文件很大要流式写入」这三条关键事实,在摘要的 key_facts 里都能找到。

验证信息保留度有个简单方法:摘要后问模型「用户之前说过什么环境限制」,看它能不能答对。如果答不出来,说明 key_facts 字段没抽好,需要调整提示词。我建议把「用户明确说的约束」单独列一个字段,比混在 key_facts 里更可靠。

压缩率不是越低越好。压到 5% 以下,细节基本丢光;10% 到 20% 是比较健康的区间。如果发现压缩率异常低,检查是不是 code_snippets 把大段代码都存进去了,代码片段要限制长度,超过 500 字符的截断。

5. 常见报错排查:401、JSON 解析失败、OAuth 问题

这一节列几个真实会遇到的报错,以及怎么定位。

第一个是 401 Unauthorized。报错长这样:

openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Invalid API key'}}

原因通常是 API Key 没配对环境变量,或者 Key 复制时带了空格。检查方法:在 Python 里打印 os.environ.get("OPENAI_API_KEY")[:8],看前几位对不对。如果用的是 TaoToken 的 Key,确认是在 API Keys 页面生成的,不是控制台登录密码。还有一种情况是 base_url 配错了,请求打到了别的服务,也会返回 401。

第二个是 JSON 解析失败:

json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

这通常是模型输出里带了 markdown 代码块标记,或者前面有「好的,以下是摘要」这类废话。解决方法是解析前先清洗:

raw = resp.choices[0].message.content.strip() if raw.startswith("```"): raw = raw.split("\n", 1)[1] raw = raw.rsplit("```", 1)[0] raw = raw.strip()

更稳的做法是用模型的 JSON mode,在请求里加 response_format={"type": "json_object"},但前提是模型支持。如果不支持,就在 system 消息里强调「只输出 JSON」。

第三个是 local proxy failed 或者 connection error。这个多半是 base_url 写错,或者网络环境有问题。先确认 base_url 是 https://taotoken.net/api ,然后用 curl 测一下连通性:

curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'

如果 curl 能通但 Python 不通,检查是不是代码里 base_url 多写了 /v1,openai 库会自动补 /v1,重复了会 404。

第四个是 reading choices 报错,比如:

AttributeError: 'NoneType' object has no attribute 'choices'

这通常是响应结构和你预期的不一样,可能是模型返回了错误信息但没抛异常。打印完整响应看看:

print(resp.model_dump_json(indent=2))

还有一种情况是流式响应没处理完就取 choices,非流式调用不会有这个问题。

如果你用 Claude Code 或者 Cline,遇到 OAuth 相关报错,检查 auth.json 或者 MCP 配置里的 base_url 和 key 是否和 TaoToken 控制台一致。三件套 Base URL、Key、Model ID 缺一不可,Model ID 写错会报 model not found。

6. 把摘要逻辑接进你的项目

最后说落地。这套摘要逻辑可以直接嵌进任何 OpenAI 兼容的对话循环里。关键是把「摘要状态」当成会话的一部分持久化,比如存 Redis 或者本地 JSON 文件,下次会话恢复时读回来。

如果你要做长期编码 Agent,摘要频率可以调低,比如每 10 轮触发一次,因为编码场景的上下文更值钱。如果做客服,摘要频率要高,每 3 轮就压一次,因为客服对话轮次多但单轮信息少。

验证模型效果的时候,可以到模型对话页面手动跑几轮,对比开摘要和不开摘要的回答质量。接入文档里有完整的参数说明,API Keys 页面管理你的密钥。长期跑编码任务的话,Coding Plan 的额度模型更适合高频调用。

代码里记得加日志,记录每次摘要前后的 token 数和字段变化。跑一周后回头看日志,你会清楚知道自己的压缩策略是不是合理。摘要不是一劳永逸的配置,而是需要根据实际对话分布持续调的参数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询