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 文档加载与切分 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。