My Blog
Agent 学习

Agent 学习第 12 周:Agent 架构,以及给它装上记忆

给 ReAct Agent 加上多轮记忆和分层压缩,上下文从线性增长变为有界;还揪出一处让模型选错工具的描述缺陷。

发布于 2026/9/11更新于 2026/9/11阅读约 25 分钟
只问这篇文章 →

第十二周是这门课的正题:Agent。

课程讲了 Agent 的定义演化、四层架构、ReAct 与 OTAC 两种循环、自主决策与任务分解。作业是为 Agent 流程增加多轮对话能力。

做完之后我最大的收获反而不在「记忆」本身,而是发现了一件更基础的事:老师给两个版本写的工具描述不一致,导致其中一版一直选错工具。这篇后半部分讲这个。


一、Agent 的定义为什么一直在变

课程开头先做了一件很诚实的事:承认这个词的定义是流动的。

2022      提示词 Agent   领域模型 + 系统提示词
2023 Q1   记忆 Agent     短期上下文 + 长期向量存储
2023 Q3   工具 Agent     Function Call,模型能「做事」
2024      规划 Agent     引入规划层,自主循环执行
2025      通用 Agent     端到端自主、跨应用、Multi-Agent

三个根源:底层能力跃升(每次跃升解锁新形态)、社区定义分裂(研究者/工程师/PM 理解不同)、商业竞争驱动(什么都叫 Agent)。

课程给出的口径很干脆:

Agent = 模型 + 记忆 + 工具 + 规划 + 循环


二、四层架构

用户输入 / 环境信号
    ↓
感知层 Perception   多源输入 → 组装成 Prompt 上下文
    ↓
规划层 Planning     LLM:理解目标 · 制定计划 · 选工具 · 评估 · 反思
    ↓
执行层 Execution    调用工具 / API / 代码,回收结果反馈给规划层
    ↓
外部工具 / API / 数据库 / 浏览器 / 文件系统

记忆层 Memory  ← 贯穿全程

规划层是 Agent 的「大脑」——它的质量决定 Agent 的上限。

执行层有个细节我记了下来:dispatch 的核心是 try 执行、except 时把异常信息作为 error 返回,而不是抛出去中断循环。这一条在上周作业里我已经踩过——异常一抛,整个循环就断了,模型连自我纠错的机会都没有。

记忆层分四种:

存什么
短期 In-Context当前对话消息,拼进 Prompt,受 Context Window 限制
长期 Vector Store重要信息向量化存 Chroma / Faiss,每轮检索注入
情景 Episodic具体事件序列(「用户上次说他的项目是 React」)
语义 Semantic通用知识和规则(「该用户偏好简洁回答」)

三、ReAct 与 OTAC

ReAct:交织推理与行动

Thought      LLM 的内部推理,分析状态、决定下一步;不触发工具
Action       调用工具,或直接输出 Final Answer
Observation  工具返回的结果,作为新上下文注入,触发下一轮 Thought

三者循环,直到 Final Answer。它解决的是两个极端的问题:「先推理后行动」无法根据中间结果调整,「直接调用工具」没有推理过程也处理不了复杂依赖。

ReAct 的四个局限里,第一条最要命:

无显式验证步骤——行动后没有 Check 环节,工具结果有误时错误会在循环中悄悄累积。

OTAC:加一个 Check

ReAct:  Thought → Action → Observation
OTAC:   Observe → Think → Act → Check

Check 后有三条路径:

触发条件动作
PASS结果符合预期写入记忆,进入下一轮
RETRY不达预期但方向对调参数或换工具,当前步重试(≤N 次)
ROLLBACK出错或走进错误路径回退到安全状态,重新 Think

设计哲学是「先做,再查」——把错误消灭在当步,而不是等任务失败了再回头排查。


四、自主决策的边界

这一节讲的是「自主」不等于「无监督」。

三态决策模型:

  • 继续执行 —— 信心充足、信息完整
  • 主动停止 —— 不确定性高,向用户提问补齐信息
  • 拒绝执行 —— 高风险或越权,明确告知并请求显式授权

原则:默认保守,宁可多问也不要盲目执行高风险操作。

课程还对比了四家企业级 Agent 的做法:

Claude Code       执行前标注风险级别,删文件/写 .env 先展示 diff 等确认
Devin             每个编码操作后截图对比期望状态(与 OTAC 的 Check 吻合)
Manus             沙箱隔离 + 关键节点存快照,用户可回滚
OpenAI Operator   付款、登录、提交表单强制暂停等授权

共性四条:最小权限、可撤销优先(草稿 > 发送,移动 > 删除)、透明行动、人在回路。


五、作业:先分清两个维度

老师的代码是一个 ReAct 范式的 A 股金融分析 Agent,五个工具:rag_search(FAISS,10,353 条年报向量)、company_lookup、financial_indicator、stock_price、calculator。两套实现:手写 Prompt 版和 Function Calling 版。

缺的是多轮对话,两个文件的 run() 一模一样:

def run(question, max_steps=10):
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user",   "content": question},   # ← 每次调用都新建
    ]

症状很具体:

问「贵州茅台2023年毛利率是多少」→ 答 91.96%
问「那五粮液呢」               → Agent 不知道「那」指毛利率
问「它的股价怎么样」            → Agent 不知道「它」是谁

容易混的一点

老师的代码已经有循环了(for step in range(1, max_steps+1)),那正是 Week11 作业做的东西。这次要补的是另一个维度:

Week11 循环 = 一个问题内部,Thought→Action→Observation 转几圈   (老师有)
Week12 多轮 = 跨问题的记忆,问完一个下一个还记得              (老师没有)

六、光把历史 append 上去是不够的

rag_search 返回的是 5 段年报原文,实测单条 1,400–2,500 字符。一个问题跑 3 步就 ≈ 4,700 字符,多轮再叠上去就线性膨胀。

所以这次作业必须是两件事一起做:记忆(存历史)+ 上下文管理(截断压缩)。

分层压缩:三层,从松到紧

最近 2 轮      完整保留 ReAct 过程,只把超长 Observation 截到 800 字符
更早的轮次     只留「问题 + 最终答案」,中间工具调用过程全丢
超预算 / 超上限 从最老的轮次开始整轮淘汰

依据是刚发生的事需要细节,久远的事只需要结论:「它的股价呢」要靠上一轮的完整上下文消解指代;三轮前查过年报哪一页,留个结论就够了。

实测:从线性增长变成有界

  轮次   不压缩(字符)   压缩后(字符)   占比
    1         4,710         2,854    60.6%
    3        14,130         5,770    40.8%
    8        37,680         6,130    16.3%
   13        56,568         6,460    11.4%   ← 已淘汰最老的 1 轮
   15        56,592         6,478    11.4%   ← 已淘汰最老的 3 轮

不压缩线性增长到 56,592 字符,压缩后收敛到 ~6,400 就不再涨了。

三层策略的接管点在曲线上看得很清楚:前 2 轮 60%(只有截断在起作用)→ 第 3 轮掉到 40% 并走平(老轮次压成一问一答,每轮只增约 80 字符)→ 第 13 轮完全持平(开始整轮淘汰)。


七、两套实现的记忆有结构性差异

这一点是动手时才发现的——两版不能用同一套压缩代码。

Function Calling 版:历史是结构化 messages,有配对约束。

带 tool_calls 的 assistant 消息后面必须紧跟 id 对得上的 tool 消息,否则 API 直接报错:

An assistant message with 'tool_calls' must be followed by
tool messages responding to each tool_call_id

所以压缩时只能整轮丢,不能丢单条。(改 tool 消息的 content 长度是安全的,不影响配对。)我为此写了配对校验,把预算压到 3,000 字符(远小于正常值)时仍验证「全部成对」。

手写版:历史是纯文本拼接,没有结构约束。 想截哪截哪,自由度高得多。代价是格式全靠 SYSTEM_PROMPT 约束,而历史文本里塞满了 Action: Observation: 这些字样,历史越长模型越容易被带偏格式。


八、2×2 对比

3 段对话共 8 轮,第一轮是完整问题(warmup 验证对比没跑偏),后 5 轮全部带指代(「那」「它」「这个数」「换成」)。

                     无记忆(老师原行为)      有记忆(本次改造)
    fc                      0/5                  5/5
    manual                  0/5                  3/5
组合              平均步数   工具调用   耗时(s)
fc+无记忆            1.2       0.2        2.3
fc+有记忆            2.4       1.4        5.9

★ 「无记忆更快」是假象:无记忆版大多数轮直接反问用户就结束了,工具调用平均只有 0.2 次——它根本没干活。快是因为什么都没做。

无记忆的两种失败形态

形态 A:反问用户(3/5)

问:把这个数除以10000是多少?
答:请提供具体的数字,我才能帮您除以10000。

形态 B:撒网式回答(2/5)——这种更隐蔽:

问:那五粮液呢?(上一轮问的是毛利率)
答:五粮液最新财务数据显示:2024年归母净利润318.5亿元…营业总收入891.8亿元…
    净资产收益率23.35%…毛利率…

它不知道问的是哪个指标,就把所有指标全报出来,总有一个命中。看起来信息量很大,实际上既没针对问题,年份还报成了 2024(问的是 2023)。

有记忆之后

FC 版 5 轮全对,最能说明问题的是这一条:

问:两家差多少个百分点?   →  2 步,只调了一次 calculator
答:贵州茅台2023年毛利率(91.96%)比五粮液(75.79%)高16.17个百分点。

没有重查任何数据——它直接从历史里取出了两个数字。记忆不只是能答出来,还省掉了重复的工具调用。


九、手写版 3/5 的真实原因:不是记忆,是工具描述

这是我这周最意外的发现。

翻明细发现手写版有记忆的两次失败,都是 rag_search 连调 3~4 次找不到数据——它压根没去调 financial_indicator,而那个工具里明明有毛利率。

根因是老师两版对同一个函数的描述不一致:

手写版:  financial_indicator(symbol) - 获取实时财务指标(PE/PB/ROE等)
FC 版:   获取A股近3年关键财务指标(营收/净利润/毛利率/ROE/资产负债率等)

而这个工具的实际返回里有:毛利率: 2023年: 91.96。

手写版的描述没写毛利率、没写净利润、没写营收,反倒写了 PE/PB——而这个工具根本不返回 PE/PB。

于是手写版的模型问「毛利率」时不认为这工具能给,转去 rag_search 翻年报原文,而年报 chunk 里没有现成的毛利率数字。

验证:只替换描述那一行

                      老师原描述          修正描述后
「换成海康威视」        rag_search×5      company_lookup → financial_indicator
「那五粮液呢?」        rag_search×3      81.92%(年报第14页)

只改一行描述,从 5 次徒劳的检索变成 2 步直接命中。

这正好印证笔记里的实战经验:「工具描述要精准,模糊描述是失败主因之一」。

所以 2×2 表里手写版那一行的差距,不能读成「文本记忆不如结构化记忆」。记忆本身是生效的(0/5 → 3/5,失败的两轮都能看到它正确理解了指代,只是选错了工具)。


十、顺带撞到的两个工具缺陷

① 失败会沿着记忆传播

stock_price 依赖的第三方接口会限流。实测一段以它开头的对话:前两轮因为接口挂了没拿到数,第三轮「这两家哪个表现更好」时模型开始病急乱投医,连调 5 次 rag_search 找别的数据来凑,跑满 10 步也没给出答案。

这正是课上说的「幻觉传播:错误的 Observation 被写进上下文,后续所有 Thought 都基于错误上下文推理」。

加了记忆之后,单轮的失败会变成整段对话的失败——这是记忆带来的新风险。

② 大额金额的 10 倍换算坑

工具返回(正确)  营业总收入: 2023年: 4.009e+11        →  4009 亿元
模型的回答(错了)「营业总收入为 400.9亿元(4.009×10¹¹元)」

科学计数法是对的,但换算成「亿」时掉了一位——模型把英文 billion 当成了中文「亿」。

最有意思的是:接下来问「宁德时代是五粮液的几倍」,答案 4.81 倍反而是对的——因为两个数错了同样的倍数,比值把误差抵消了。

单看比值发现不了这个错。

所以选演示题时我避开了大额金额,用百分比类指标(毛利率 / ROE)最稳——百分比直接读取,不需要换算。


这周的收获

1. 记忆的代价是错误会传播。

无记忆时,一轮失败就是一轮失败;有记忆时,一轮的错误 Observation 会污染后面所有推理。这让我理解了为什么 OTAC 要专门加一个 Check——循环 + 记忆 = 错误放大器,必须在每一步拦住它。

2. 工具描述是代码的一部分,而且是最容易被忽略的那部分。

改一行 description,模型的行为从「5 次徒劳检索」变成「2 步命中」。Prompt 里的自然语言就是接口契约,写得含糊等于接口写错。

3. 归因要做到底。

如果我只看 2×2 那张表,会写出「结构化记忆优于文本记忆」这个漂亮但错误的结论。多翻一层明细,才发现问题根本不在记忆上。 这次是靠一次随手复核发现的。

下周讲 Skills 与 Harness——Agent 的能力怎么组织和加载。

相关文章