《AI Agent 开发入门》9.3 记忆即工具

本节把 9.2 的混合检索封装成 recall(query) 工具注册进 ToolRegistry,让 Agent 在循环里自己决定何时回忆。用假模型脚本化「用户提问 → 调用 recall → 拿到记忆 → 给出回答」并真跑打印轨迹,最后对比记忆即工具与自动注入在自主性和确定性上的取舍。

9.3 记忆即工具

9.1 造出了向量索引,9.2 把关键词和向量融合成一条稳健的检索链。但还有一个悬而未决的问题:这条检索链该在什么时候被调用? 一种做法是每轮对话开始前,系统自动拿用户输入去检索、把命中记忆塞进提示词;另一种做法是把它包成一个工具,交给模型自己决定"这一刻我要不要回忆"。后者更符合 Agent 的精神——把能力交给模型,把决定权也交给模型。

本节把 minagent 推进到 minagent/memory_tool.py:把 9.2 的 hybrid_search 封装成 recall(query) 工具注册进 ToolRegistry,用假模型跑出一条"提问 → 回忆 → 回答"的完整轨迹,并对比"记忆即工具"与"自动注入"的取舍。

9.3.1 把 recall 注册成工具

第 4 章立下的 Tool 契约很窄:一个名字、一段描述、一份 JSON Schema、一个函数。工具函数返回字符串,出错时返回 {"error": ...} 而不抛异常。recall 正好能套进去——它吃一个查询串,吐一段 JSON 记忆列表。

先把 Tool 和 ToolRegistry 照第 4 章的定义摆出来(一字未改):

from dataclasses import dataclass
from typing import Any, Callable

@dataclass
class Tool:
    name: str
    description: str
    parameters: dict[str, Any]
    func: Callable[..., Any]

    def schema(self) -> dict[str, Any]:
        return {"name": self.name, "description": self.description,
                "input_schema": self.parameters}

class ToolRegistry:
    def __init__(self) -> None:
        self._tools: dict[str, Tool] = {}

    def add(self, tool: Tool) -> None:
        self._tools[tool.name] = tool

    def schemas(self) -> list[dict[str, Any]]:
        return [t.schema() for t in self._tools.values()]

    def call(self, tc: ToolCall) -> str:
        tool = self._tools.get(tc.name)
        if tool is None:
            return json.dumps({"error": f"unknown tool: {tc.name}"}, ensure_ascii=False)
        try:
            result = tool.func(**tc.arguments)
        except Exception as e:
            return json.dumps({"error": f"{type(e).__name__}: {e}"}, ensure_ascii=False)
        return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)

接下来是 recall 本身。它直接复用 9.2 的 hybrid_search——关键词 + 向量 + 融合这一整套,被压成了一个函数:

import json

RECORDS_BY_ID = {r[0]: {"title": r[1], "body": r[2]} for r in RECORDS}

def recall(query: str, k: int = 3) -> str:
    hits = hybrid_search(query, k)
    return json.dumps([{"id": mid, **RECORDS_BY_ID[mid]} for mid, _ in hits],
                      ensure_ascii=False)

把 recall 注册进注册表:

registry = ToolRegistry()
registry.add(Tool(
    name="recall",
    description="按语义从长期记忆中召回相关片段。需要回忆用户偏好、项目约定或历史决定时调用。",
    parameters={"type": "object",
                "properties": {"query": {"type": "string"}, "k": {"type": "integer"}},
                "required": ["query"]},
    func=recall,
))

description 是这里唯一"给模型看"的字段,它决定了模型什么时候会想到用这个工具。写"从记忆中召回片段"太笼统,模型不会主动用;写成"需要回忆用户偏好、项目约定或历史决定时调用",等于给模型一条明确的触发规则。打印生成的 schema:

print(json.dumps(registry.schemas(), ensure_ascii=False, indent=2))

实测输出(Python 3.14.6):

[
  {
    "name": "recall",
    "description": "按语义从长期记忆中召回相关片段。需要回忆用户偏好、项目约定或历史决定时调用。",
    "input_schema": {
      "type": "object",
      "properties": {
        "query": {
          "type": "string"
        },
        "k": {
          "type": "integer"
        }
      },
      "required": [
        "query"
      ]
    }
  }
]

required 里只有 query,k 是可选的——模型不传 k 时,函数默认取 3。这跟 9.1、9.2 的默认值一脉相承:接口给模型留的旋钮越少,它越不容易用错。

9.3.2 让 Agent 跑起来:核心类型与假模型

要跑出轨迹,先得有第 1、3 章立下的那几个核心类型。它们是整卷的地基,这里原样复用:

from dataclasses import dataclass, field
from typing import Any, Literal, Protocol

Role = Literal["system", "user", "assistant", "tool"]

@dataclass
class ToolCall:
    id: str
    name: str
    arguments: dict[str, Any]

@dataclass
class Message:
    role: Role
    content: str = ""
    tool_calls: list[ToolCall] = field(default_factory=list)
    tool_call_id: str | None = None

@dataclass
class ModelResponse:
    message: Message
    stop_reason: str = "end_turn"
    usage: dict[str, int] = field(default_factory=dict)

class Model(Protocol):
    def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse: ...

@dataclass
class Step:
    index: int
    kind: str
    detail: str

@dataclass
class RunResult:
    messages: list[Message]
    steps: list[Step]
    final: str

FakeModel 接收一个脚本列表,每项要么是字符串(文本回复,结束),要么是 ToolCall 列表(本轮发出工具调用),要么是 ModelResponse(原样返回)。它还会把每次调用的入参记下来,供测试断言:

class FakeModel:
    def __init__(self, script: list[Any]) -> None:
        self.script = list(script)
        self.calls: list[tuple[list[Message], list[dict]]] = []

    def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse:
        self.calls.append((list(messages), list(tools)))
        if not self.script:
            raise RuntimeError("script exhausted")
        item = self.script.pop(0)
        if isinstance(item, ModelResponse):
            return item
        if isinstance(item, str):
            return ModelResponse(message=Message(role="assistant", content=item))
        return ModelResponse(message=Message(role="assistant", tool_calls=item),
                             stop_reason="tool_use")

run 是第 3 章的循环:把消息和 registry.schemas() 交给模型,模型要么给文本(结束),要么给 tool_calls(执行、回灌、再循环)。每走一步就记一个 Step:

def run(model: Model, registry: ToolRegistry, user_input: str,
        system: str = "", max_steps: int = 8) -> RunResult:
    messages = [Message(role="system", content=system),
                Message(role="user", content=user_input)]
    steps: list[Step] = []
    for i in range(max_steps):
        resp = model.complete(messages, registry.schemas())
        messages.append(resp.message)
        if not resp.message.tool_calls:
            steps.append(Step(index=i, kind="final", detail=resp.message.content))
            return RunResult(messages=messages, steps=steps, final=resp.message.content)
        for tc in resp.message.tool_calls:
            output = registry.call(tc)
            steps.append(Step(index=i, kind="tool_call",
                              detail=f"{tc.name}({tc.arguments}) -> {output}"))
            messages.append(Message(role="tool", content=output, tool_call_id=tc.id))
    raise AgentLoopError("max_steps", RunResult(messages, steps, ""))

值得留意循环里的一个细节:判断"该结束还是该继续"只看 tool_calls 是否为空——模型不再要求调用工具,就是收敛。这是第 3 章立下的判据,比只看 stop_reason 更稳:真实供应商偶尔会在带文本的回复里也标 stop_reason="tool_use",而 tool_calls 为空是硬事实。步数撞到 max_steps 时,按第 3 章的做法抛 AgentLoopError("max_steps", ...),把已产生的部分结果一并带出去。

9.3.3 脚本化轨迹:用户问 → recall → 回答

现在让 Agent 跑起来。用一个 FakeModel 脚本,第一步发出 recall 调用,第二步给出最终回答——这正好复现"模型先想、再查记忆、最后回答"的两轮循环:

script = [
    [ToolCall(id="c1", name="recall", arguments={"query": "工具调用失败怎么返回"})],
    "根据记忆 m5:工具调用失败时不要抛异常,应返回 {\"error\": ...} 的 JSON,让模型下一轮自己决定重试还是换参数。",
]
model = FakeModel(script)
result = run(model, registry, "我们的工具报错时应该怎么返回给模型?",
             system="你是 minagent 的编程助手。")
for st in result.steps:
    print(f"[step {st.index}] {st.kind}: {st.detail}")
print("\nfinal:", result.final)

实测输出:

[step 0] tool_call: recall({'query': '工具调用失败怎么返回'}) -> [{"id": "m5", "title": "工具错误处理", "body": "调用失败时不要抛异常,返回 error JSON。"}, {"id": "m2", "title": "依赖安装", "body": "pip 装包必须用中科大镜像源,官方源在本机不稳定。"}, {"id": "m3", "title": "语言偏好", "body": "用户希望简体中文回复,代码注释保持英文。"}]
[step 1] final: 根据记忆 m5:工具调用失败时不要抛异常,应返回 {"error": ...} 的 JSON,让模型下一轮自己决定重试还是换参数。

轨迹清清楚楚地展示了三步:模型自己决定调用 recall(step 0),工具返回了 3 条记忆、m5 排第 1,模型读到 m5 后组织出最终回答(step 1)。整条链路上,检索没有被硬编码进循环,而是像 search、calculator 一样,是模型从工具清单里主动挑出来用的。

这正是"记忆即工具"的核心:记忆不是背景板,而是一件模型随时可以伸手去拿的工具。用户问"我们的约定是什么",模型可以调 recall;用户只是打招呼,模型就完全不碰它。调用与否的判断,落在模型的推理里,而不是我们的 if 里。

还可以用 model.calls 断言轨迹。第一次 complete 时,模型看到的入参里应该有 system、user 两条消息,外加 recall 的 schema;第二次 complete 时,消息里应该多出 assistant(带 tool_calls)和 tool(带召回结果)两条。这种"看模型到底拿到了什么"的断言能力,正是假模型相对真实 API 的最大优势——真实 API 我们只能看到输入输出,看不到"模型当时有没有看到那条记忆"。

9.3.4 记忆即工具 vs 自动注入

另一种同样常见的做法是自动注入:每轮循环前,系统自动用用户输入检索一次,把命中的记忆拼进 system 提示词。两种方式各有所长:

维度记忆即工具(recall)自动注入
谁决定检索时机模型系统(每轮都做)
检索次数按需,可能为 0固定,每轮至少 1 次
确定性低,取决于模型高,逻辑写死
可测性需要假模型脚本才能复现直接断言注入内容
提示词占用只在需要时占每轮都占
失败模式模型忘了调,漏掉记忆无关记忆被硬塞,干扰模型

自动注入的优点是确定:不管模型怎么想,该出现的记忆一定会出现在上下文里,测试也好写——直接断言"提示词里含 m5"。缺点是笨:哪怕用户只是说"你好",系统也照样检索、照样塞进几条可能无关的记忆,白白占 token、还可能把模型带偏。

记忆即工具反过来:自主、省 token,但不确定。模型可能压根不调 recall,于是本该用上的记忆被漏掉;也可能反复调、绕圈子。它把"何时回忆"这个判断外包给了模型,收益是灵活,代价是不可控。

一个务实的折中是两者都用:对"必须出现"的硬约束(比如安全规则、当前用户身份)用自动注入,保证一定在;对"可能有用"的软知识(历史讨论、项目约定)用工具,让模型按需取。第 10 章做检索增强时会再回到这个权衡,那里注入的是外部文档,而这里注入的是 Agent 自己的记忆。

还要点一句:recall 能被模型用好的前提,是工具描述写对了。如果描述写成"查询数据库",模型永远想不到在"该回忆点什么"时去用它。工具描述本质上是给模型的一句提示词,写得好不好,直接决定这个能力是否被激活——这也是第 4 章反复强调"description 是提示词的一部分"的原因。

真实模型下会怎样:真实 Claude 在拿到 recall 的 schema 后,会自己判断要不要调用——用户问"我们之前定过什么约定"时几乎一定会调,用户闲聊时通常不调。但它也会犯错:可能把明显该回忆的场景漏过去(该调不调),也可能在信息已经足够时多调一次(不该调乱调)。所以"记忆即工具"在真实系统里通常要配一条兜底规则,比如"首轮若命中某些关键词则强制注入"。另一个差异是并行与多轮:真实模型可能一次发出多个 recall(比如同时查"用户偏好"和"项目约定"),而本节的分发层一次只处理一个 tool_call;到了第 4.3 节和第 13 章,我们会让循环支持并行工具调用,那时 recall 也能一次查多个角度。

阅读导航:上一节:9.2 混合检索:关键词 + 向量 + 重排 · 下一节:10.1 文档加载与切分 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距