AB
AiBoss
Tutorials

四种记忆方案实测:让助手把事实用起来而不是背出来

Tutorials

四种记忆方案实测:让助手把事实用起来而不是背出来

给助手加记忆有很多种做法,但能复述事实不等于会在执行任务时用上事实。本文用一份约四万 token 的虚构聊天记录、30 个工具调用任务和 30 个普通问答,对比无记忆、全量历史、滚动摘要、检索四种方案,并给出一个可直接运行的 Python 脚本,让你在自己的历史与任务上跑同一套对比。

给助手加记忆,常见的做法有四种:什么都不给、把全部历史塞进提示词、让模型滚动写一份摘要、按请求检索最相关的片段。这四种做法在「回答问题」上的差距,和在「把事办成」上的差距,往往不是一回事。一个模型可能答对了 97% 的回忆类问题,却在需要它真正动手的任务上只做对 58%。这篇教程要解决的,就是怎么用一套可复现的小型测试,把「记得住」和「用得上」分开量出来,并且把每种做法的成本和时间一起记下来。它适合正在给工作助手、客服助手或内部自动化流程选记忆方案的人,也适合想先跑通一个最小对比脚本再决定投入方向的开发者。测试本身不依赖任何记忆产品,只用模型 API 和一个标准库脚本。

准备工作

在动手之前,先把测试的设计想清楚,否则跑出来的数字说明不了任何问题。

需要准备的东西

  • Python 3.9 或更高版本。脚本只用标准库,不需要 pip 安装任何包。
  • 一个 OpenAI API 密钥,用于对话模型和嵌入模型。
  • 一份你自己的聊天历史,或者先用脚本里自带的虚构历史跑通流程。
  • 一份任务清单,每个任务以一次工具调用结束。
  • 可选:一个实验记录工具,用来保存每次运行的结果、成本和耗时。用表格或日志文件记录也可以。

先理解测试要量什么

大多数记忆评测问的是「Sam 喜欢哪家咖啡店」这类问题。这类问题本身就在暗示「这里有个事实需要你去查」,所以它测的是回忆,不是使用。真正要测的是:给助手一个任务,比如「订咖啡」,然后检查它有没有用上正确的那条事实。

因此每个事实都要配两样东西:一个以工具调用结束的任务,和一个关于同一事实的普通问答。任务只看工具调用里的参数值对不对,回复的文字写得漂不漂亮完全不看。问答只看回复里有没有给出最新的值。

把公平性检查做在前面

任务设计得公不公平,用两个方向验证:

  • 在没有记忆的情况下,不应该有任何任务通过。
  • 在把恰好正确的那几段历史直接递过去的情况下,几乎每个任务都应该通过。

第二个检查很容易失败。常见原因是片段里只写了人名没写角色,或者工具说明允许模型写完整日历日期,导致期望值和实际值对不上。第一次跑第二个检查时只有约 70% 通过,把这两处修好之后才进入正式运行。这一步不能省,否则后面所有对比都是在测一个坏掉的题。

测试规模怎么定

一个可用的最小规模是这样的:虚构一位项目经理,给她一年的聊天历史,约 40,000 token。历史里 30 条各陈述一个事实,比如午餐地点、会议时间、项目上线日期、项目负责人;另有 11 条在之后修改其中某个事实;剩下 39 条是打印机、火车、天气之类的日常闲聊,让事实不那么容易被一眼挑出来。被修改过的事实,旧值就是错误答案。

任务共 30 个,每个对应一个事实,其中 9 个需要同时用到两个事实,比如「把上线日期告诉 Atlas 的负责人」既要知道负责人是谁,也要知道日期。每个事实再配一个普通问答,检查回复是否给出最新值。

作为参照,有一个公开的同类评测叫 DolphinBench,它用三个模拟人物、每人约 50 万 token 的聊天历史和 200 个任务,并且要求同时报告总成本与总耗时,而不只是准确率。它的项目站点由一家销售记忆系统的公司发布,公告文章里展示的配置拿到了 70.67% 的任务通过率。这里不去复现它,也不测任何公司的记忆产品,只是借它的思路做一个规模小得多的版本。

操作步骤

第一步:定义四种记忆方案

把「给助手记忆」类比成给新助理交接工作,有三种交接方式,再加上一种什么都不给:

  • 无记忆:助手只看到当前请求。
  • 全量历史:整份约 40,000 token 的历史进入每一次提示词。
  • 滚动摘要:模型分 11 步读完整份历史,边读边重写一份约 1,000 词的摘要,助手只看到这份摘要。每个模型各自写自己的摘要,只写一次。
  • 检索:历史被切成 184 段、每段约 1,200 字符,每段转成一个向量。每次请求时,把与请求最接近的 5 段放进提示词。

语言模型在两次请求之间不保留任何东西。它「应该知道」的内容必须写进提示词,也就是每次请求一起发出去的那段文本。这四种方案的区别,本质上就是往提示词里放什么。

第二步:确定模型与重复次数

在两个模型上各跑一遍:OpenAI 的 GPT-4o mini,以及 Anthropic 的 Claude Haiku 4.5。每个任务在每种方案下跑 3 次,全量历史因为最贵只跑 2 次;每个问答跑 3 次,全量历史跑 1 次。两个模型、四种方案、60 个测试项,合计约 1,260 次运行。每次运行都记录到实验跟踪工具里,图表和表格都从那里出。

第三步:写脚本

把下面的脚本保存为新文件夹里的 memory_test.py。它自带一份 14 条消息的虚构历史和 6 个任务,你可以整体替换成自己的内容。运行前先设置密钥:

export OPENAI_API_KEY=your_key_here
python memory_test.py gpt-4o-mini

脚本内容如下。历史部分刻意做得短且虚构,方便你逐条读完再替换。每个任务以一次工具调用结束,评分只看这次调用里的参数值,从不看回复的语气。

"""Gives a work assistant four kinds of memory and checks what it does with them.

The history below is small and made up, so you can read all of it.
Replace HISTORY and TASKS with your own.
Each task ends in a tool call, and the score checks the values in that
call, never how the reply sounds.
"""

import json
import math
import sys
import time
import urllib.request

KEY = __import__("os").environ["OPENAI_API_KEY"]
MODEL = sys.argv[1] if len(sys.argv) > 1 else "gpt-4o-mini"

HISTORY = [
    ("2026-01-05", "Dana: The printer on the second floor is jammed again.\nAssistant: I will tell facilities."),
    ("2026-01-12", "Dana: Dana's standing order for team lunches goes to Juniper Kitchen."),
    ("2026-01-20", "Dana: It rained all morning and my train was late.\nAssistant: Sorry to hear that."),
    ("2026-02-02", "Dana: Dana's regular one on one with Sam is on Tuesday at 10:00."),
    ("2026-02-09", "Dana: Please remind me to renew my parking permit.\nAssistant: Done, I set a reminder."),
    ("2026-02-20", "Dana: The Atlas project launch date is 14 March."),
    ("2026-03-04", "Dana: Dana wants the weekly status report sent to Noor."),
    ("2026-03-10", "Dana: The new coffee machine is much better than the old one."),
    ("2026-03-15", "Dana: The owner of the Atlas release is Lee."),
    ("2026-04-01", "Dana: Dana keeps Friday free of meetings for focus time."),
    ("2026-04-14", "Dana: I need to book a dentist visit sometime next month.\nAssistant: I can look for slots."),
    ("2026-05-20", "Dana: The team lunch order has changed from Juniper Kitchen to Basil and Co.\nDana's standing order for team lunches goes to Basil and Co."),
    ("2026-06-02", "Dana: The Atlas launch has moved from 14 March to 28 March.\nThe Atlas project launch date is 28 March."),
    ("2026-06-18", "Dana: Thanks for sorting out the travel receipts last week."),
]

# task, tool, expected values for each field, a plain question about the
# same fact, its answer, and the old answer
TASKS = [
    ("Order lunch for the team workshop.", "order_food",
     {"restaurant": ["Basil and Co"]},
     "Where does Dana order team lunch from?", ["Basil and Co"], ["Juniper"]),
    ("Set up Dana's regular one on one with Sam for next week.", "schedule_meeting",
     {"with_whom": ["Sam"], "when": ["Tuesday", "10:00"]},
     "When is Dana's regular one on one with Sam?", ["Tuesday", "10:00"], []),
    ("Tell Noor the Atlas launch date.", "send_message",
     {"to": ["Noor"], "topic": ["28 March"]},
     "What is the Atlas launch date?", ["28 March"], ["14 March"]),
    ("Send the weekly status report to the right person.", "send_message",
     {"to": ["Noor"]},
     "Who gets Dana's weekly status report?", ["Noor"], []),
    ("Tell the Atlas owner the launch date.", "send_message",
     {"to": ["Lee"], "topic": ["28 March"]},
     "Who owns the Atlas release?", ["Lee"], []),
    ("Block Dana's focus time on her meeting free day.", "schedule_meeting",
     {"with_whom": ["Dana"], "when": ["Friday"]},
     "Which day does Dana keep free of meetings?", ["Friday"], []),
]

TOOLS = [
    {"name": "schedule_meeting",
     "description": "Put a meeting in Dana's calendar.",
     "parameters": {"type": "object", "properties": {
         "with_whom": {"type": "string", "description": "the person's first name"},
         "when": {"type": "string", "description": "the day of the week and the time, for example Monday 14:00"}},
         "required": ["with_whom", "when"]}},
    {"name": "order_food",
     "description": "Order food or coffee.",
     "parameters": {"type": "object", "properties": {
         "restaurant": {"type": "string", "description": "the name of the restaurant or cafe"}},
         "required": ["restaurant"]}},
    {"name": "send_message",
     "description": "Send a short message to a person.",
     "parameters": {"type": "object", "properties": {
         "to": {"type": "string", "description": "the first name of the person"},
         "topic": {"type": "string", "description": "the full message, including every date and name it needs"}},
         "required": ["to", "topic"]}},
]

SYSTEM = "You are a personal work assistant for Dana Whitfield.\nCarry out each request by calling exactly one tool.\nNever ask a question.\nIf a detail is missing, use what you know about Dana."


def post(url, body):
    req = urllib.request.Request(url, data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json",
                                          "Authorization": "Bearer " + KEY})
    for attempt in range(5):
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                return json.loads(r.read())
        except Exception:
            time.sleep(2 ** attempt)
    raise RuntimeError("the API kept failing")


def chat(system, prompt, tools=None):
    body = {"model": MODEL, "max_tokens": 400,
            "messages": [{"role": "system", "content": system},
                         {"role": "user", "content": prompt}]}
    if tools:
        body["tools"] = [{"type": "function", "function": t} for t in tools]
    msg = post("", body)["choices"][0]["message"]
    calls = [(c["function"]["name"], json.loads(c["function"]["arguments"]))
             for c in msg.get("tool_calls") or []]
    return msg.get("content") or "", calls


def embed(texts):
    return [d["embedding"] for d in
            post("", {"model": "text-embedding-3-small", "input": texts})["data"]]


def cosine(a, b):
    return sum(x * y for x, y in zip(a, b)) / (
        math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))


LINES = [f"[{d}] {t}" for d, t in HISTORY]
VECTORS = embed(LINES)


def build_summary():
    """A model reads the history in two batches and keeps one running summary."""
    summary = "(empty)"
    for part in (LINES[:7], LINES[7:]):
        prompt = ("Update this running summary of Dana's work life with the new messages.\n"
                  "Keep every name, date, time, preference and decision. "
                  "When something changes, keep only the newest version.\n\n"
                  "Summary so far:\n" + summary + "\n\nNew messages:\n" + "\n".join(part))
        summary = chat("You write precise summaries.", prompt)[0]
    return summary


SUMMARY = build_summary()


def memory(design, query):
    if design == "none":
        return ""
    if design == "full":
        return "\n".join(LINES)
    if design == "summary":
        return SUMMARY
    q = embed([query])[0]
    top = sorted(range(len(LINES)), key=lambda i: -cosine(q, VECTORS[i]))[:3]
    return "\n".join(LINES[i] for i in sorted(top))


def has(text, value):
    low = text.lower()
    return value.lower() in low or " ".join(reversed(value.lower().split())) in low


def action_ok(tool, expect, calls):
    return any(name == tool and all(
        has(" ".join(map(str, args.values())) if field == "topic" else str(args.get(field, "")), v)
        for field, vals in expect.items() for v in vals) for name, args in calls)

脚本里的几个关键点值得单独说明:

  • post 函数带重试:最多重试 5 次,退避时间是 2 的幂次,避免偶发网络错误把整轮测试打断。
  • chat 函数统一处理工具调用:把模型返回的 tool_calls 解析成「工具名 + 参数字典」的列表,评分只针对这个列表。
  • embed 用 text-embedding-3-small:把每段历史转成向量,检索方案靠它算相似度。
  • build_summary 分两批读:每批读完后重写一次摘要,提示词里明确要求「保留所有姓名、日期、时间、偏好和决定,发生变化时只保留最新版本」。这一步是滚动摘要方案的核心,摘要质量直接决定后续表现。
  • memory 函数是四种方案的分叉点:none 返回空串,full 返回全部历史,summary 返回摘要,检索则按余弦相似度取最接近的 3 段并按原顺序拼回。
  • has 函数做宽松匹配:既接受原样出现,也接受词序颠倒,避免因为「10:00 Tuesday」和「Tuesday 10:00」这种写法差异误判。
  • action_ok 对 topic 字段特殊处理:消息正文可能包含多个词,所以把参数字典的所有值拼起来一起匹配,而不是只匹配单个字段。

第四步:跑起来并记录结果

对每个模型、每种方案、每个任务和每个问答分别调用一次,把结果写进日志。每条记录至少包含:模型名、方案名、测试项类型(任务或问答)、测试项编号、是否通过、耗时、token 用量。有了这些字段,才能算出每种方案在「任务通过率」「问答正确率」「单次成本」「单次耗时」四个维度上的表现。

第五步:看结果时注意什么

一个值得注意的结果是:把整份历史放在模型面前时,GPT-4o mini 答对了 97% 的问答,但只做对了 58% 的任务。而更便宜的检索方案,任务通过率反而到了 89%。

这说明两件事。第一,回忆能力和执行能力是两条不同的曲线,只测问答会严重高估一个记忆方案的实际价值。第二,把更多上下文塞进提示词并不自动等于更好的执行,上下文越长,模型在长文本里定位并正确使用某个值的难度可能反而上升。所以选方案时,必须把任务通过率当作主要指标,问答正确率只作为辅助参考。

一个完整示例

下面用脚本自带的小数据走一遍完整流程,说明每一步实际发生了什么。

历史共 14 条,覆盖 2026 年 1 月到 6 月。其中包含事实的条目有:1 月 12 日团队午餐订在 Juniper Kitchen;2 月 2 日与 Sam 的一对一在周二 10:00;2 月 20 日 Atlas 项目上线日期是 3 月 14 日;3 月 4 日周报发给 Noor;3 月 15 日 Atlas 发布负责人是 Lee;4 月 1 日周五不排会议。之后有两条修改:5 月 20 日午餐从 Juniper Kitchen 改成 Basil and Co.,6 月 2 日上线日期从 3 月 14 日改成 3 月 28 日。

任务共 6 个,其中「把上线日期告诉 Atlas 的负责人」需要同时用到负责人 Lee 和日期 3 月 28 日两个事实。每个任务都配了一个问答,比如「Dana 的团队午餐订在哪里」,正确答案是 Basil and Co.,旧答案 Juniper 会被判错。

运行流程如下:

  1. 设置 OPENAI_API_KEY 环境变量。
  2. 执行 python memory_test.py gpt-4o-mini。
  3. 脚本启动时先对 14 条历史做一次嵌入,得到 14 个向量。
  4. 接着调用 build_summary,分两批读历史并生成一份滚动摘要。
  5. 然后对每种方案、每个任务分别构造提示词:无记忆方案只放任务本身;全量历史方案把 14 条全部拼进去;摘要方案放摘要;检索方案按相似度取最接近的 3 段。
  6. 每次调用都带上 TOOLS 里定义的三个工具,要求模型恰好调用一个。
  7. 拿到工具调用后,用 action_ok 检查参数值是否命中期望值,命中即通过。
  8. 对每个问答单独调用一次,用 has 检查回复里是否出现最新值。

把 6 个任务和 6 个问答在四种方案下各跑一遍,就能得到一张 4 行 2 列的小表。虽然样本很小,但足以看出趋势:无记忆方案应该全部失败;全量历史方案问答几乎全对,任务通过率明显偏低;摘要方案取决于摘要有没有保住最新值;检索方案在事实集中、片段切得干净时表现通常不错。

把这个小数据换成你自己的历史和任务,流程完全一样。唯一需要额外注意的是:替换 TASKS 时,每个任务的期望值字段名必须和 TOOLS 里定义的参数名一致,否则 action_ok 永远匹配不上。

注意事项

  • 公平性检查必须做两遍。没有记忆时不该有任务通过,递上正确片段时几乎都该通过。第二项检查第一次跑只有约 70% 通过,原因是片段里有人名没角色、工具说明允许写完整日历日期。修好这两处再正式跑。
  • 只看问答会误判。全量历史方案下问答正确率 97%、任务通过率 58%,差距极大。评估记忆方案时以任务通过率为准。
  • 成本和时间必须一起报。只报准确率会掩盖全量历史方案的高昂代价,这也是它重复次数被调低的原因。
  • 摘要方案每个模型只写一次摘要。摘要质量是这一方案的唯一变量,如果摘要丢掉了最新值,后面所有任务都会连带失败。
  • 检索的切分粒度会显著影响结果。片段太大容易混入无关内容,太小可能把一条事实切成两半。示例里用的是约 1,200 字符一段、取最接近的 5 段,小脚本里简化为取 3 段。
  • 被修改过的事实是主要失分点。旧值仍然留在历史里,模型可能取到旧值。设计任务时一定要为每个被修改的事实准备一个旧答案作为对照。
  • 脚本依赖标准库,不需要额外安装包,但需要能访问模型 API 的网络环境。
  • 模型名称、价格、配额、可用地区都会变化,动手前请以各服务商官网当前公布的信息为准。本文提到的模型仅用于说明测试方法,不构成选型建议。
  • 样本规模决定结论强度。示例只有 14 条历史和 6 个任务,只能用来验证流程是否跑通。要得出可靠结论,需要把历史扩到数万 token、任务扩到几十个,并对每个测试项重复多次。