AI Agents in Action · 第六章

给智能体
外挂两种脑子

模型本身不记事——每次对话都是失忆重来。这一章讲怎么给它外挂一个脑子, 而办法有两种形状完全不同的:一种是把资料切碎、按「意思像不像」捞回来(向量/RAG), 另一种是把事实存成一张实体关系网(图记忆)。 它们擅长的问题类型不一样,这一章的最后是把两种一起上

向量记忆 · RAG
一堆碎片散在空间里,
按「离问题多近」捞几个回来
图记忆 · 知识图谱
实体连着实体,
能回答「谁跟谁什么关系」

这一章是全书坑最密的一章。开始之前请务必先读下面那段。

动手之前 —— 这一章的雷特别多

这个目录里有 14 个文件是 0 字节的空壳

chapter_06/有 14 个 .py 文件完全是空的, 一个字节都没有。它们的名字极具迷惑性:

test_chroma_client.py     test_chroma_direct.py    test_chroma_mcp.py
test_mcp_connection.py    test_mcp_protocol.py     test_mcp_tools.py
test_minimal_chroma.py    clear_memory.py         recreate_collection.py
debug_mcp_agent.py        03_mcp_RAG_agent.py     02_RAG_agent_vecotr.py
05_hybrid_memory_agent_clean.py     03_mcp_memory_agent_with_recovery.py

别去找 clear_memory.py 来清记忆,它是空的。 别以为那 7 个 test_*.py 是测试,它们也是空的—— 整个仓库一个测试都没有,requirements.txt 里连 pytest 都没装。 看到可疑文件,先 wc -c 一下。

另外注意 02_RAG_agent_vecotr.py(拼错的 vecotr,空的)和 02_RAG_agent_vector.py(拼对的,89 行,这个才是真的) 就差两个字母,很容易打开错的那个然后一脸茫然。

这一章必须从仓库根目录跑

前几章的代码用 Path(__file__),从哪跑都行。这一章不是—— 它把路径写死成了相对路径:

Path("chapter_06/sample_documents/back_to_the_future.txt")
path="./chapter_06/chroma_script_store"

所以只能 python chapter_06/02_RAG_agent_vector.pycd chapter_06 再跑必然找不到文件。这也和第 10 章正好相反 (那一章必须 cd 进去)——全仓库没有一个 cwd 能跑通所有章节。

requirements.txt 少装了 scikit-learn

document_vector_database.pydocument_vector_similarity.pydocument_visualizing_embeddings.py 三个文件都 from sklearn... import, 但 requirements.txt没有 scikit-learn,开箱即 ModuleNotFoundError。先补一句:

uv pip install scikit-learn
好消息:这一章的 embedding 不花钱,也不用第三方接口

代码里有句注释说这是「with OpenAI embeddings」——这句话是错的。 它建集合时明确没有传 embedding function,所以 Chroma 用的是自带的本地模型。 我实测确认过:把 OPENAI_API_KEY 从环境里彻底删掉,检索照样成功, 而且向量维度是 384——这是 all-MiniLM-L6-v2 的维度, 不是 OpenAI embedding 的 1536。

对用第三方模型的你来说这是好事:这一章的检索部分完全不需要 embedding 接口, 只有智能体聊天那部分才走你的模型。

代价是第一次运行会下载 79 MB 的 ONNX 模型到 ~/.cache/chroma/,看起来像卡住了,其实在下。没网就跑不了。

第一部分 · 打地基

先不碰模型,搞懂「像」是怎么算的

这一节一行 LLM 代码都没有,纯粹用 44 行讲明白检索的内核: 把文字变成一串数字,然后比较两串数字的夹角

TF-IDF + 余弦相似度:RAG 的祖先

对应 6.1 · 需要先装 scikit-learn

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

# 示例文档
documents = [
    "The sky is blue and beautiful.",
    "Love this blue and beautiful sky!",
    "The quick brown fox jumps over the lazy dog.",
    "A king's breakfast has sausages, ham, bacon, eggs, toast, and beans",
    "I love green eggs, ham, sausages and bacon!",
    "The brown fox is quick and the blue dog is lazy!",
    "The sky is very blue and the sky is very beautiful today",
    "The dog is lazy but the brown fox is quick!"
]

# 第一步:用 TF-IDF 把文档变成向量
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(documents)

# 第二步:把向量存进一个极简的「向量数据库」(这里就是个列表)
vector_database = X.toarray()

# 第三步:余弦相似度检索
def cosine_similarity_search(query, database, vectorizer, top_n=5):
    query_vec = vectorizer.transform([query]).toarray()
    similarities = cosine_similarity(query_vec, database)[0]
    top_indices = np.argsort(-similarities)[:top_n]  # 取前 n 个下标
    return [(idx, similarities[idx]) for idx in top_indices]

# 交互式检索循环
while True:
    query = input("输入检索词(输入 exit 退出):")
    if query.lower() == 'exit':
        break
    top_n = int(input("想看前几条结果?"))
    search_results = cosine_similarity_search(query, vector_database, vectorizer, top_n)

    print("最匹配的文档:")
    for idx, score in search_results:
        print(f"- {documents[idx]} (相似度: {score:.4f})")

    print("\n")

注意这里的文档我没翻译,因为 TF-IDF 是按词统计的—— 文档是英文,你就得用英文词去查才有命中。这是个小预告: 检索的语言必须和语料的语言对上,下一节这件事会变成大问题。

TF-IDF 和真正的 embedding 差在哪

TF-IDF 只会数词:"blue sky""azure heavens" 在它眼里毫无关系,因为一个词都没重合。 而真正的 embedding 模型知道这俩意思差不多。

那为什么还要学它?因为「向量化 → 算夹角 → 取最近的几个」这套骨架, 和下一节的 Chroma 是一模一样的。区别只在于向量是怎么来的。 先在 44 行里把骨架看清楚,再去用黑盒子。

顺带一提:while True: input() 意味着这个脚本会一直等你敲字。 本章好几个文件都是这样,自动化里跑会挂住。

第二部分 · 向量记忆

RAG:把一整个剧本塞进智能体的脑子

语料是仓库自带的《回到未来》剧本(sample_documents/back_to_the_future.txt,137 KB)。 选它是有讲究的:模型多半没背过完整剧本,所以它答得出细节, 就只能是真的检索到了——和第七章那些编造的知识是同一个套路

89 行:切碎 → 存进 Chroma → 包成一个工具

对应 6.3 · 注意是 vector 不是 vecotr

import uuid
from pathlib import Path

import chromadb
import tiktoken
from agents import (Agent, Runner, function_tool,
                    set_default_openai_api, set_tracing_disabled)
from dotenv import load_dotenv

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()
# 下面两行仓库原文没有,是为了接第三方模型加的
set_default_openai_api("chat_completions")
set_tracing_disabled(True)

# ------------------------------------------------------------------
# 1. 读入剧本并切块
#    注意:这是相对路径,必须从仓库根目录运行
# ------------------------------------------------------------------
script_text = Path("chapter_06/sample_documents/back_to_the_future.txt").read_text(
    encoding="utf-8"
)


def simple_chunk(text, max_tokens=200):
    tokenizer = tiktoken.get_encoding("cl100k_base")
    words, chunk, chunks = text.split(), [], []
    for w in words:
        if len(tokenizer.encode(" ".join(chunk + [w]))) > max_tokens:
            chunks.append(" ".join(chunk))
            chunk = [w]
        else:
            chunk.append(w)
    if chunk:
        chunks.append(" ".join(chunk))
    return chunks


docs = simple_chunk(script_text, max_tokens=200)

# ------------------------------------------------------------------
# 2. 建一个 Chroma 集合(或连上已有的)
#    仓库原注释说这里用的是 OpenAI embeddings —— 那是错的,
#    没传 embedding function,用的是 Chroma 自带的本地模型
# ------------------------------------------------------------------
client = chromadb.PersistentClient(
    path="./chapter_06/chroma_script_store"  # 存在磁盘上,之后可以复用
)
collection_name = "bttf_script"

# 先试着拿已有的集合
try:
    collection = client.get_collection(collection_name)
except Exception:
    # 集合不存在就建一个(不指定 embedding function)
    collection = client.create_collection(name=collection_name)

# 只灌一次数据(已经有内容就跳过)
if collection.count() == 0:
    collection.add(ids=[str(uuid.uuid4()) for _ in docs], documents=docs)


# ------------------------------------------------------------------
# 3. 定义一个查 Chroma 的语义检索工具
# ------------------------------------------------------------------
@function_tool
def search_script(query: str, top_k: int = 3) -> str:
    res = collection.query(query_texts=[query], n_results=top_k)
    if res and "documents" in res and res["documents"] and res["documents"][0]:
        return "\n\n".join(res["documents"][0])  # 把最相关的几块拼起来
    return "No relevant documents found."


# ------------------------------------------------------------------
# 4. 组装智能体
# ------------------------------------------------------------------
agent = Agent(
    name="Script Agent",
    instructions=(
        "你负责回答关于电影《回到未来》的问题。\n"
        "需要的时候,调用 `search_script` 工具取回原文片段,"
        "然后在回答里引用或转述它们。"
    ),
    tools=[search_script],
)

# ------------------------------------------------------------------
# 5. 提问
#    查询保持英文 —— 语料是英文剧本,中文查询会检索不准,见下方说明
# ------------------------------------------------------------------
query = "Where does Doc tell Marty to meet him, and at what time?"
result = Runner.run_sync(agent, query)
print("\n--- 回答 ---\n", result.final_output)

query = "What happens at 1:15AM"
result = Runner.run_sync(agent, query)
print("\n--- 回答 ---\n", result.final_output)
中文读者必看:查询别翻成中文

这一章我刻意没有翻译那两句查询,因为翻了会真的坏掉。 语料是英文剧本,而 Chroma 默认的 all-MiniLM-L6-v2 是个以英文为主的模型。我拿仓库预置的向量库实测了一下:

查询 'clock tower'        → 距离 1.1658
查询 '钟楼'              → 距离 1.6480  而且捞到的是完全不同的段落

距离越小越相关。中文查询不但分数差了一大截,捞回来的根本不是同一段。 智能体会拿着一堆不相干的片段一本正经地编答案——不报错,只是答得不对, 这是最难排查的一类失败。

这条规律比这一章更重要查询的语言必须和语料的语言、 以及 embedding 模型支持的语言三方对齐。真要做中文 RAG, 得换一个多语言 embedding 模型(比如 paraphrase-multilingual-MiniLM 或你服务商的 embedding 接口),而不是指望默认那个。

为什么第一次跑不用等切块

if collection.count() == 0: 这一句是说「已经有数据就别灌了」。 而仓库已经把灌好的向量库提交进去了—— chapter_06/chroma_script_store/18 MB,里面躺着 167 个切好的 chunk(我数过)。所以你第一次跑不会重新 embedding,直接就能查。

方便是方便,但有副作用:这 18 MB 的二进制没被 gitignore.gitignore 里只有 Django 模板的 db.sqlite3, 匹配不到 chroma.sqlite3)。你一旦重新灌数据, HNSW 索引文件就变了,git status 立刻变脏。

第三部分 · 图记忆

另一种脑子:把事实存成一张网

向量记忆擅长「把像的段落捞出来」,但它答不好一类问题: 「张三的老板是谁?」——这需要的不是相似度,是关系。 这就是图记忆的地盘。

一行 npx,白捡一个知识图谱

对应 6.4

又是第三章那个套路:npx 拉起一个别人写好的 MCP server。 这次是 @modelcontextprotocol/server-memory—— 它维护一张由实体(entities)、关系(relations)、观察(observations) 组成的知识图谱。

import asyncio

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from agents.mcp import MCPServerStdio
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)


async def main():
    # 先把 server 实例化出来……
    memory_srv = MCPServerStdio(
        name="memory",
        params={
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-memory@latest"],
        },
    )

    instructions = """
每次交互都遵循以下步骤:

1. 识别用户身份:
   - 默认认为你在和 default_user 对话
   - 如果还没识别出 default_user,主动去确认

2. 检索记忆:
   - 每次对话开始时,只说「Remembering...」,然后从知识图谱里取回所有相关信息
   - 始终把你的知识图谱称作你的「记忆」
   使用记忆工具时,遵循以下步骤:
   a) 输入必须是合法的 JSON

3. 记忆
   - 与用户交谈时,留意以下几类新信息:
     a) 基本身份(年龄、性别、所在地、职位、教育程度等)
     b) 行为(兴趣、习惯等)
     c) 偏好(沟通风格、偏好语言等)
     d) 目标(目标、指标、愿景等)
     e) 关系(个人与职业关系,最多三度)

4. 更新记忆:
   - 如果交互中获得了新信息,按以下方式更新记忆:
     a) 为反复出现的组织、人物和重要事件创建实体
     b) 用关系把它们连到已有实体上
     c) 把关于它们的事实作为观察存下来
    """
    # ……然后打开它
    async with memory_srv:
        agent = Agent(
            name="Memory Agent",
            instructions=instructions,
            mcp_servers=[memory_srv],
        )

        # 交互式提问循环
        while True:
            try:
                user_input = input("记忆助手:(输入 exit 退出):")
                if user_input.strip().lower() in ("exit", "quit"):
                    break
                response = await Runner.run(agent, user_input)
                print(response.final_output)
            except (EOFError, KeyboardInterrupt):
                print("\n退出。")
                break


if __name__ == "__main__":
    asyncio.run(main())
这一节真正的知识点全在提示词里

看清楚了:Python 部分毫无新意——就是第三章那个 MCPServerStdio。 这一节 66 行里有 27 行是提示词,而它才是全部重点

因为图记忆不会自动工作。memory server 只提供「建实体、连关系、存观察」这些工具, 什么时候该记、该记什么、怎么归类,全靠你在提示词里教。 那句「每次对话开始时先说 Remembering...」看着傻,其实是在强迫模型养成先查记忆的习惯—— 不这么写,它就会忘了自己有记忆这回事。

这和第二章「docstring 是给模型看的」是同一个道理,只是规模大了一号: 工具给你能力,提示词决定这个能力会不会被用上。

这个脚本是交互式的

while True: input(...) ——它会停下来等你打字, 这是本章唯一能真正体验「记忆」的方式:你得多轮对话, 先告诉它一些关于你的事,退出,再重新跑一遍,看它记不记得。 单跑一次是看不出效果的。

配套的 03_create_memories_mcp.py(185 行)则是批量灌数据用的, 它把《回到未来》的人物关系预先塞进知识图谱,省得你手动喂。

第四部分 · 合体

两种脑子一起上

04_hybrid_memory_agent.py 把前两种接到同一个智能体上: 一个连 memory server(图),一个连 chroma server(向量)

混合记忆:两个 MCP server,一个智能体

对应 6.4.3

async def main():
    # 图记忆:知识图谱
    memory_srv = MCPServerStdio(
        name="memory",
        params={
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-memory@latest"],
        },
    )

    # 语义记忆:ChromaDB 向量库
    # 注意这里用的是 uvx,不是 npx —— chroma-mcp 是个 Python 包
    chroma_srv = MCPServerStdio(
        name="chroma",
        params={
            "command": "uvx",
            "args": ["chroma-mcp", "--data-dir", "chapter_06/chroma_script_store"],
        },
    )

    instructions = """
你是一个混合记忆管理智能体,把语义记忆(ChromaDB 向量库)
和图记忆(知识图谱)结合起来,提供完整的记忆能力。

关键:每一次对话你都必须先说「Remembering...」,
然后执行混合记忆检索流程。

混合记忆系统:
1. 语义记忆(ChromaDB):存对话内容、文档和上下文信息
2. 图记忆(知识图谱):存结构化的事实、关系、实体和观察
    """
    # …… 后面还有很长一段提示词,规定两种记忆各自什么时候用 ……

    async with memory_srv, chroma_srv:
        agent = Agent(
            name="Hybrid Memory Agent",
            instructions=instructions,
            mcp_servers=[memory_srv, chroma_srv],
        )
        # …… 交互循环,同上 ……
这里出现了 uvx —— 正好是我们在用的工具链

"command": "uvx",不是 npx。因为 chroma-mcp 是个 Python 包,不是 npm 包。 uvx 之于 Python,就相当于 npx 之于 Node—— 不安装,直接跑

我们从第二章起就在用 uv,所以 uvx 你已经有了, 不用额外装什么。这也顺带说明 MCP 的一个好处: server 是什么语言写的、怎么起来的,调用方完全不关心—— 一个 npx、一个 uvx,在 agent 眼里长得一模一样。

注意 --data-dir chapter_06/chroma_script_store 又是个相对路径, 再次确认这一章必须从仓库根目录跑。

混合记忆的难点不在代码,在「什么时候用哪个」

代码上就是 mcp_servers=[memory_srv, chroma_srv],多加一个而已。 真正的活全在提示词里——原文那段 instructions 长得吓人, 通篇在规定「查人物关系走图、查剧情内容走向量、存事实走图、存对话走向量」。

这就是这一章的落点:两种记忆不是谁替代谁,是分工向量答「讲了什么」,图答「谁跟谁什么关系」。 而让模型学会分工的唯一手段,还是提示词。

跟着书走之前,先看这几条