临床 Python 进阶路线图
⌕ /
路线图 › 第五阶段 · Agent 核心能力

第 21 章 · 记忆与上下文管理

本章目标:理解为什么"上下文窗口很大"并没有解决记忆问题, 并掌握三种对话记忆策略、结构化工作记忆、以及临床文档检索的正确做法。 配套模块: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)

Python
def build_context_full(state) -> list[Message]:
    return state.messages          # 全部历史
优点 缺点
信息无损,实现最简单 成本随时间线性增长,最终必然超窗

适用:短任务(< 10 轮)、调试期。

策略 2:滑窗(Sliding Window)

Python
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)

Python
# 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 结构化工作记忆:让模型"读",而不是"记"

这是本章最实用的一条经验:

不要指望模型"记住"之前算出的结果。把结果放进状态对象,每轮重新喂给它。

Python
# ❌ 靠对话记忆
# 轮次 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 |

这带来三个好处:

  1. 数字不会串 —— 它来自状态,不是模型的记忆
  2. 可溯源 —— 每个事实带着出处,写进报告时能直接引用
  3. 可测试 —— 断言 state.facts["adsl.n_rows"].value == 254 就是一个回归测试

🔥 这和临床数据管理的思路完全一致: 不做"口头传达",做"源数据核查"(SDV)。 让结论永远可回溯到一个可核查的原始记录。


21.5 长期记忆:什么时候真的需要检索

不是所有项目都需要 RAG。先看判断标准:

你的信息 需要 RAG 吗
几十页的项目规范 不需要 —— 直接放 System Prompt
几百页的 SAP + CRF + define.xml 需要
上次核查发现的问题清单 需要(且应该结构化存起来,不只是检索)
受控术语(CT)词典 不需要 —— 那是查表,不是检索
相似项目的既往代码 需要

核心区别:信息量超过上下文预算,且只有一小部分与当前问题相关 → 需要检索。

最小可用实现

不必一上来就上向量数据库。临床文档有一个很好的特性: 结构清晰、章节明确。所以"按章节切分 + 关键词检索"往往就够用:

Python
# 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 检索结果的正确用法

必须做到:可溯源引用

Python
# ❌ 不可核查
"根据 SAP 的规定,主要疗效分析应使用 ITT 人群。"
# 监管问:SAP 第几节说的?

# ✅ 可核查
"根据 SAP 第 6.3.2 节(引用自 SAP_v2.1.pdf,第 34 页):
 主要疗效分析基于 ITT 人群。"

所以检索块的元数据里必须带足够定位的信息:

Python
@dataclass
class Chunk:
    doc: str
    section: str        # "6.3.2"
    title: str
    page: int | None    # PDF 页码
    text: str

必须做到:检索不到就说没找到

Python
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:

Python
{
    "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。

Python
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 章讲了工具返回要"小而结构化", 但还有一个更狠的做法:长结果进上下文前再压一次。

Python
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 本章小结

  1. 上下文是预算,不是仓库 —— 装得越满,准确率越低
  2. 摘要 + 滑窗比全量或纯滑窗都好,临床场景推荐混合
  3. 数字放状态,不放对话 —— 让模型"读事实表"而不是"回忆"
  4. 每个事实带出处 —— 这是能写进报告的前提
  5. 检索必须可溯源 —— 章节号与页码,否则不可核查
  6. 检索不到就说没找到 —— 这条不能妥协

下一章:什么时候真的需要多个 Agent,以及它们之间怎么不打架。


21.11 动手练习

  1. 给 clinic/agent_memory.py 的 SummarizingMemory 加一条断言: 摘要中必须保留所有出现的 6 位以上数字。跑一次,看会不会失败。
  2. 构造一个 15 轮的对话,分别用三种记忆策略构建上下文, 统计各自的字符数并画出增长曲线。
  3. 把 KeywordRetriever 用在 docs/ 目录上: 把 18 章教程切成 chunk,然后检索"缺失值和 SAS 的 . 有什么区别", 看 Top-3 是否命中第 10 章。
  4. 给 render_tool_result 加一个"保留首尾各 N 行"的模式 (有些结果的尾部才是关键,比如错误信息)。
  5. 思考题:如果审计要求"Agent 每次给出的数字都能追溯到源数据", 你会怎么设计 Fact 结构?(提示:除了工具名,还需要什么才能回溯到原始记录?)