本章目标:理解为什么"上下文窗口很大"并没有解决记忆问题, 并掌握三种对话记忆策略、结构化工作记忆、以及临床文档检索的正确做法。 配套模块:
clinic/agent_memory.py
21.1 上下文窗口是硬约束
模型宣传的"200K 上下文"给了很多人一个错觉:不用管记忆了。
实际情况是,上下文每多 1 万 token,你要付三笔账:
| 账 | 说明 |
|---|---|
| 钱 | 输入 token 是计费的,且每轮都要重发全部历史 —— 第 20 轮的成本是第 1 轮的 20 倍 |
| 时间 | 上下文越长,首 token 延迟越高,交互体验断崖式下降 |
| 准确率 | 关键信息落在长上下文中间时最容易被忽略(业界称 "lost in the middle") |
第三笔账最容易被忽视,也最危险。
🔥 一个反直觉的结论:把 200 页 SAP 全文塞进上下文, 效果通常不如先检索出相关的 3 条条款再放进去。 前者看起来"信息完整",实际上让模型在噪音里捞针。
所以这一章的核心不是"怎么装下更多",而是"怎么只装该装的"。
21.2 上下文里应该放什么
一个可用的预算分配表(以 32K 上下文为例):
| 内容 | 预算 | 说明 |
|---|---|---|
| System Prompt | 1–2K | 角色、硬规则、输出格式 |
| 工具清单 | 2–4K | 工具多了这块会暴涨(见 20.3 的粒度) |
| 任务目标 + 当前计划 | 1K | 每轮都带上,防止"跑偏" |
| 工作记忆(状态摘要) | 2–4K | ★ 替代"记住之前发生的事" |
| 最近的 3–5 轮原始对话 | 3–6K | 保留细节,供模型理解上下文 |
| 工具调用结果 | 4–8K | 必须摘要化(见 21.6) |
| 检索到的外部依据 | 2–6K | 带出处 |
| 留给输出的空间 | 2–4K | 别把窗口填满,模型要写答案 |
注意"工作记忆"和"最近对话"是两件不同的事 —— 很多实现把它们混在一起,结果就是"要么全留(爆窗口)要么全丢(失忆)"。
21.3 三种对话记忆策略
策略 1:全量保留(Full)
def build_context_full(state) -> list[Message]:
return state.messages # 全部历史
| 优点 | 缺点 |
|---|---|
| 信息无损,实现最简单 | 成本随时间线性增长,最终必然超窗 |
适用:短任务(< 10 轮)、调试期。
策略 2:滑窗(Sliding Window)
def build_context_window(state, keep: int = 5, keep_head: int = 2) -> list[Message]:
"""保留最前面的(system + 初始目标)+ 最近的 N 轮。"""
msgs = state.messages
if len(msgs) <= keep + keep_head:
return msgs
return msgs[:keep_head] + [Message.system(
f"(此处省略了 {len(msgs) - keep - keep_head} 轮中间对话)"
)] + msgs[-keep:]
| 优点 | 缺点 |
|---|---|
| 成本恒定 | 中间的关键结论会丢 |
适用:探索型任务,中间过程不重要。
⚠️ 滑窗有个隐蔽的坑:tool 消息必须跟在对应的 assistant 消息后面。
如果窗口边界正好切在"assistant 发了 tool_call"和"tool 返回结果"之间,
API 会报 400 Bad Request。上面的实现没有处理这个边界 ——
生产中需要向后对齐到安全的切点。
策略 3:摘要压缩(Summary)
# clinic/agent_memory.py(节选)
class SummarizingMemory:
"""把旧对话压缩成结构化摘要,而不是直接丢弃。"""
SUMMARY_PROMPT = """把下面的对话压缩成简明的结构化摘要。
必须保留:① 已经确认的事实与数字(含出处)② 已排除的可能性 ③ 未解决的问题
不要保留:寒暄、重复的推理过程、失败尝试的细节
输出格式:
## 已确认
- ...
## 已排除
- ...
## 待解决
- ...
"""
def __init__(self, llm, keep_recent: int = 4, max_summary_chars: int = 1500):
self.llm = llm
self.keep_recent = keep_recent
self.max_summary_chars = max_summary_chars
self.summary: str = ""
def build(self, state) -> list[Message]:
msgs = state.messages
if len(msgs) <= self.keep_recent + 1:
return msgs
old, recent = msgs[1:-self.keep_recent], msgs[-self.keep_recent:]
if old:
self.summary = self._compress(self.summary, old)
head = Message.system(f"## 早前对话摘要\n{self.summary}") if self.summary else None
return [msgs[0]] + ([head] if head else []) + recent
三种策略的取舍:
| 策略 | 成本 | 信息保留 | 复杂度 | 适用 |
|---|---|---|---|---|
| 全量 | 线性增长 | 完整 | 低 | 短任务、调试 |
| 滑窗 | 恒定 | 只保最近 | 低 | 探索型、过程不重要 |
| 摘要 | 恒定 | 关键结论不丢 | 中 | 长任务、需要追溯 |
🔥 临床场景推荐"摘要 + 滑窗"混合: 摘要负责"不丢结论",滑窗负责"保住最近细节"。 纯摘要有信息损失风险,纯滑窗会丢掉早期的关键发现。
⚠️ 摘要本身也是 LLM 调用,它会引入新的失真。两条纪律:
1. 摘要必须保留数字和出处("ADSL 254 行"不能在摘要里变成"约 250 行")
2. 原始对话不要删 —— 摘要只是给模型看的视图,原始记录仍在 state.steps 里供审计
21.4 结构化工作记忆:让模型"读",而不是"记"
这是本章最实用的一条经验:
不要指望模型"记住"之前算出的结果。把结果放进状态对象,每轮重新喂给它。
# ❌ 靠对话记忆
# 轮次 3:assistant 说"ADSL 共 254 行"
# 轮次 9:模型要引用这个数字 → 可能记错、记串
# ✅ 靠结构化状态
@dataclass
class AgentState:
...
artifacts: dict[str, Any] = field(default_factory=dict)
"""工作记忆:工具产出的事实,按 key 存放。每轮渲染进上下文。"""
facts: list[Fact] = field(default_factory=list)
@dataclass
class Fact:
key: str # "adsl.n_rows"
value: Any # 254
source: str # "describe_dataset(dataset='adsl')"
step: int # 3
渲染进上下文时是一小块固定格式的表:
## 已确认的事实(来自工具,可直接引用)
| 事实 | 值 | 出处 |
|---|---|---|
| adsl.n_rows | 254 | describe_dataset(dataset='adsl') @step3 |
| dq.high_issues | 0 | run_qc_checks(dataset='adsl') @step4 |
| dq.medium_issues | 3 | run_qc_checks(dataset='adsl') @step4 |
这带来三个好处:
- 数字不会串 —— 它来自状态,不是模型的记忆
- 可溯源 —— 每个事实带着出处,写进报告时能直接引用
- 可测试 —— 断言
state.facts["adsl.n_rows"].value == 254就是一个回归测试
🔥 这和临床数据管理的思路完全一致: 不做"口头传达",做"源数据核查"(SDV)。 让结论永远可回溯到一个可核查的原始记录。
21.5 长期记忆:什么时候真的需要检索
不是所有项目都需要 RAG。先看判断标准:
| 你的信息 | 需要 RAG 吗 |
|---|---|
| 几十页的项目规范 | 不需要 —— 直接放 System Prompt |
| 几百页的 SAP + CRF + define.xml | 需要 |
| 上次核查发现的问题清单 | 需要(且应该结构化存起来,不只是检索) |
| 受控术语(CT)词典 | 不需要 —— 那是查表,不是检索 |
| 相似项目的既往代码 | 需要 |
核心区别:信息量超过上下文预算,且只有一小部分与当前问题相关 → 需要检索。
最小可用实现
不必一上来就上向量数据库。临床文档有一个很好的特性: 结构清晰、章节明确。所以"按章节切分 + 关键词检索"往往就够用:
# clinic/agent_memory.py(节选)
@dataclass
class Chunk:
doc: str # 文件名
section: str # 章节号,如 "6.3.2"
title: str
text: str
class KeywordRetriever:
"""基于关键词打分的检索器。零依赖,对结构化文档效果好。
打分 = 命中词数 / 查询词数 + 标题命中加权
"""
def __init__(self, chunks: list[Chunk]):
self.chunks = chunks
self._index = [self._tokenize(c.text + " " + c.title) for c in chunks]
@staticmethod
def _tokenize(s: str) -> set[str]:
# 中文按 2-gram + 英文按单词。对"缺失值""受试者"这类词足够有效
s = s.lower()
words = set(re.findall(r"[a-z_][a-z0-9_]{2,}", s))
cjk = re.findall(r"[\u4e00-\u9fff]", s)
words |= {"".join(cjk[i:i + 2]) for i in range(len(cjk) - 1)}
return words
def search(self, query: str, top_k: int = 3) -> list[tuple[Chunk, float]]:
q = self._tokenize(query)
if not q:
return []
scored = []
for chunk, toks in zip(self.chunks, self._index):
hit = len(q & toks)
if not hit:
continue
score = hit / len(q)
if q & self._tokenize(chunk.title):
score += 0.3 # 标题命中更相关
scored.append((chunk, score))
scored.sort(key=lambda x: -x[1])
return scored[:top_k]
超过 200 个文档再考虑向量检索。在那之前,关键词检索的可解释性 (你能说清"为什么召回了这一段")反而是临床场景更看重的性质。
21.6 检索结果的正确用法
必须做到:可溯源引用
# ❌ 不可核查
"根据 SAP 的规定,主要疗效分析应使用 ITT 人群。"
# 监管问:SAP 第几节说的?
# ✅ 可核查
"根据 SAP 第 6.3.2 节(引用自 SAP_v2.1.pdf,第 34 页):
主要疗效分析基于 ITT 人群。"
所以检索块的元数据里必须带足够定位的信息:
@dataclass
class Chunk:
doc: str
section: str # "6.3.2"
title: str
page: int | None # PDF 页码
text: str
必须做到:检索不到就说没找到
def answer_with_citation(query: str, retriever, llm) -> str:
hits = retriever.search(query, top_k=3)
if not hits or hits[0][1] < 0.25: # ★ 低分阈值
return ("在提供的文档中没有找到与该问题相关的规定。"
"请确认文档范围,或提供相关章节。")
...
⚠️ 这是 RAG 在临床场景的红线。 一个"检索不到却编了一条看起来合理的规则"的助手, 比一个"检索不到就说不知道"的助手危险一百倍 —— 因为前者会被当成 SAP 依据使用。
检索在 Agent 循环里怎么用
检索不应该只在开始时做一次。真实用法是作为工具暴露给 Agent:
{
"name": "search_sap",
"description": (
"在项目 SAP 文档中检索相关条款,返回原文片段与章节号。"
"当你需要确认某个分析口径时使用本工具,不要凭记忆回答。"
"如果返回空结果,说明文档中没有相关规定,请如实说明。"
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "用中文描述你要确认的问题"},
"top_k": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3},
},
"required": ["query"],
"additionalProperties": False,
},
}
描述里那句"不要凭记忆回答"是关键 —— 这是把"检索优先"写进契约。
21.7 上下文的成本控制
粗估 token
中文场景下一个够用的经验值:1 个汉字 ≈ 0.6–1 个 token,英文 1 词 ≈ 1.3 token。
def rough_tokens(text: str) -> int:
"""粗略估算 token 数。够用即可,不必精确。"""
cjk = len(re.findall(r"[\u4e00-\u9fff]", text))
other = len(text) - cjk
return int(cjk * 0.8 + other * 0.3)
⚠️ 这只能用来做预算控制,不能用来计费 —— 计费要用 API 返回的真实
usage 字段。
工具结果摘要化
这是省 token 最有效的一招。第 20 章讲了工具返回要"小而结构化", 但还有一个更狠的做法:长结果进上下文前再压一次。
def render_tool_result(data: Any, max_chars: int = 1200) -> str:
"""把工具结果渲染进上下文,超长则摘要化。"""
text = json.dumps(data, ensure_ascii=False, indent=None)
if len(text) <= max_chars:
return text
# 保留结构骨架 + 前若干行,中间明确省略
if isinstance(data, dict) and "行" in data:
rows = data["行"]
keep = max(3, max_chars // 90)
trimmed = dict(data, 行=rows[:keep],
说明=f"(此处省略 {len(rows) - keep} 行,"
f"总数 {data.get('总数', len(rows))})")
return json.dumps(trimmed, ensure_ascii=False)
return text[:max_chars] + f"…(已截断,原长 {len(text)} 字符)"
配合 21.4 的事实表,一个 8 轮的核查任务可以稳定控制在 8K token 上下, 而不是随轮次无限膨胀。
21.8 一个真实例子:SAP 条款助手
把上面零件拼起来,就是一个很实用的东西 —— 临床统计组日常真的需要它:
输入:一个自然语言问题
"AE 汇总表的分母用哪个数据集?"
Agent 循环:
1. search_sap(query="AE 汇总表 分母 分析人群")
→ 命中 SAP 第 8.2 节 + 第 6.1 节
2. 判断需要确认"安全性人群"的定义
→ search_sap(query="安全性人群 定义 SAFFL")
→ 命中 SAP 第 6.1.2 节
3. 汇总回答(带出处)
输出:
「AE 汇总表的分母为安全性人群(Safety Population),定义为至少接受过
一剂研究药物的受试者(SAP 第 6.1.2 节)。
分子为发生 TEAE 的受试者数(去重到受试者层级,SAP 第 8.2 节)。
注意:分母应使用 ADSL.SAFFL='Y' 的受试者数(254),
而非随机化人数(306)。」
引用:
· SAP v2.1 第 6.1.2 节 · 安全性人群定义
· SAP v2.1 第 8.2 节 · AE 汇总表规则
注意输出的三个特征:有数字(254/306)、有出处(章节号)、 有反例提示("而非 306")。这正是把"文档检索"变成"可用工具"的关键。
21.9 常见误区
| 误区 | 后果 | 正确做法 |
|---|---|---|
| "上下文大就不用管记忆" | 成本涨、延迟涨、准确率降 | 主动管理上下文预算 |
| 记忆 = 保存所有对话 | 必然超窗 | 摘要 + 滑窗 + 结构化状态 |
| 靠模型"记住"工具结果 | 数字记串、幻觉 | 事实表放状态里,每轮重喂 |
| 摘要时丢掉数字 | 254 变成"约 250" | 摘要必须保留精确值和出处 |
| 检索结果不带出处 | 无法核查,等于没有 | 章节号 + 页码 |
| 检索不到就编 | 临床场景的红线 | 低分阈值 + 明确说"没找到" |
| 一上来就上向量库 | 复杂度高、不可解释 | 结构化文档先试关键词检索 |
| 用粗估 token 计费 | 算不准 | 用 API 返回的真实 usage |
21.10 本章小结
- 上下文是预算,不是仓库 —— 装得越满,准确率越低
- 摘要 + 滑窗比全量或纯滑窗都好,临床场景推荐混合
- 数字放状态,不放对话 —— 让模型"读事实表"而不是"回忆"
- 每个事实带出处 —— 这是能写进报告的前提
- 检索必须可溯源 —— 章节号与页码,否则不可核查
- 检索不到就说没找到 —— 这条不能妥协
下一章:什么时候真的需要多个 Agent,以及它们之间怎么不打架。
21.11 动手练习
- 给
clinic/agent_memory.py的SummarizingMemory加一条断言: 摘要中必须保留所有出现的 6 位以上数字。跑一次,看会不会失败。 - 构造一个 15 轮的对话,分别用三种记忆策略构建上下文, 统计各自的字符数并画出增长曲线。
- 把
KeywordRetriever用在docs/目录上: 把 18 章教程切成 chunk,然后检索"缺失值和 SAS 的 . 有什么区别", 看 Top-3 是否命中第 10 章。 - 给
render_tool_result加一个"保留首尾各 N 行"的模式 (有些结果的尾部才是关键,比如错误信息)。 - 思考题:如果审计要求"Agent 每次给出的数字都能追溯到源数据",
你会怎么设计
Fact结构?(提示:除了工具名,还需要什么才能回溯到原始记录?)