手把手开发AI Agent:规划、工具调用与记忆实战
手把手开发AI Agent:规划、工具调用与记忆实战
过去两年里,我参与过不少被冠以「AI Agent」之名的项目。有做得漂亮的,也有半路夭折的,还有那种在演示视频里行云流水、一进真实环境就在第三步开始胡言乱语的。到2026年的今天,模型能力已经比两年前强了不止一个量级,长上下文、原生工具调用、结构化输出几乎成了标配,可我发现,真正决定一个 Agent 能不能上生产的,从来不是模型本身,而是围绕它的那套工程骨架:怎么规划、怎么调工具、怎么记住该记的东西。
这篇文章不打算再讲一遍「什么是 Agent」,也不想罗列一堆框架的对比表格。我想把自己反复踩坑、反复重构之后攒下来的那套做法完整摊开:从最朴素的循环讲起,一路讲到规划、工具契约、记忆分层,最后落到评估与可观测性。
先说结论,我的核心观点只有一句:Agent 不是更聪明的提示词,而是一个被约束住的决策循环。你越是把不确定性交给模型自由发挥,它就越容易在第三步迷路;你越是把边界、契约和状态定义清楚,它反而越像样。这句话听起来朴素,但它几乎解释了我在这个领域见过的所有成功与失败。
一、先别急着装框架:把「循环」想明白
绝大多数人上手的第一件事,是挑一个 Agent 框架,然后开始配 YAML、写工具描述、调参数。我不推荐这么干。框架会替你隐藏掉最重要的东西——那个 while 循环里到底发生了什么。
一个 Agent 的本质,用最朴素的话说就是:观察当前状态 → 决定下一步 → 执行 → 把结果写回状态 → 判断是否结束。这五件事循环往复,直到任务完成或者触发终止条件。LLM 在其中承担的只是「决定下一步」这一环,而这一环之外的所有东西——状态的表示、工具的执行、错误的翻译、上下文的装配、终止的判定——都是你要写的代码。
我见过太多项目在这一步就翻了车。最常见的做法是把整个循环塞进一次超长的提示词里,让模型自己在文本中完成「思考—行动—观察」的循环。这在演示里很惊艳,在真实场景里却几乎必然崩溃:上下文会爆,模型会在第四轮忘记最初的目标,工具报错会以一段毫无意义的栈信息回到模型面前,然后它开始编造一个看起来合理的返回值。
所以我的第一条建议是:自己手写一遍循环,哪怕只有五十行。写过一遍,你才知道框架到底帮你省了什么,也才知道它在哪里偷偷给你埋了坑。很多所谓「框架解决不了的问题」,其实是你根本没看清循环里发生了什么。
还有一层更隐蔽的好处:手写循环会强迫你把「状态」这个词想清楚。状态是什么?是当前目标、是已确认的约束、是已完成的步骤、是待验证的假设。想清楚了状态,后面的规划、工具、记忆才有地方安放。
二、规划:从「让它自己想办法」到「给它一副骨架」
2.1 ReAct 很好,但它不是终点
ReAct 式的「思考—行动」交替,是最容易想到的规划方式:每一步都让模型想一下,然后调一个工具,看到结果再想下一步。它灵活、实现简单,特别适合探索性的、边界不清的任务。
但它的三个毛病在生产环境里会被无限放大。
第一个是目标漂移。模型没有全局视角,它只看得见最近几步,很容易被最新一条工具返回牵着走。用户问的是「帮我找出上一季度转化率下滑的原因」,它在第三步发现了一张数据表的结构问题,然后就一头扎进去修表,把最初的问题忘得干干净净。它不是在偷懒,它只是在优化眼前这个局部目标。
第二个是上下文膨胀。每一轮思考和观察都要留在历史里,二十步之后,上下文里全是无关紧要的中间产物,真正重要的那几句反而被淹没在噪声里。更麻烦的是,这种膨胀往往是不可逆的,因为你很难判断中间哪一条观察其实是关键的。
第三个是无终止保证。模型判断「任务完成」的标准极其模糊。它要么早早收工,给出一个自己都不太信的答案;要么在同一个动作上反复横跳,每一次都觉得自己在推进。
2.2 我现在的做法:显式计划加动态重规划
我的做法是折中:先要一份显式的、结构化的计划,但允许它在执行中被推翻重写。
具体来说,在收到任务后,我会先让模型输出一份计划。但这份计划不是自然语言的「首先……然后……最后……」,而是一个结构化的列表,每一项至少包含四样东西:子目标的描述、它依赖的前置步骤、完成这个子目标的验收标准、以及大致需要哪些工具。
结构化这一步非常关键,它带来的收益远超你的想象。首先,你可以校验它——依赖关系有没有环、引用的工具存不存在、验收标准是不是可判定的。其次,你可以渲染给用户看,让人类在第一步就发现方向跑偏了,而不是等到十步之后。第三,执行时你可以把「当前子目标」单独拎出来放进提示词,让模型的注意力聚焦在一件事上。第四,重规划时你可以对比新旧计划的差异,知道模型到底改了哪里。这些事,自然语言计划一件都做不到。
执行时,我维护一个子目标栈。做完一个就出栈;如果某个子目标的验收标准没有通过,就把它重新入栈,并附上具体的失败原因。当模型判断当前计划已经无法推进——比如同一个子目标连续两次以同样的方式失败——就触发重规划:把原始目标、已完成的步骤摘要、当前的失败现场一起给它,让它重新出一份计划。
这里有一个很实用的细节:重规划时,我会把「已经排除的假设」也一并传给模型。比如「我们已经确认问题不在渠道 A」,那么新计划里就不该再出现渠道 A。没有这一条,模型会在重规划里绕回原地,你会看到它兴高采烈地给出一个和第一版几乎一样的计划。
2.3 反思要有,但别让它变成自我感动
反思(Reflection)是很多教程极力推荐的一步:让模型评价自己刚才的表现。它确实有用,但极易退化成废话生成器——「我做得不错,但还有改进空间」。
我的经验是,反思必须绑定一个具体问题,并输出可执行的结论。比如不要问「你刚才做得怎么样」,而要问:「刚才这次搜索返回的结果,是否足以支撑子目标 X 的验收标准?如果不足,缺的是哪一类信息?下一步应该用哪个工具、带什么参数去补?」
前一种问法得到的是情绪,后一种问法得到的是动作。
另外,反思不要太频繁。每一步都反思,成本翻倍,收益却迅速递减。我一般在两个时刻触发它:子目标完成时,以及连续失败两次时。前者用来确认「我们真的做完了吗」,后者用来判断「是不是该换条路了」。
2.4 终止条件要写在代码里,不是写在提示词里
「任务完成」这件事,不能全靠模型点头。我会在代码层面设几道硬闸门:最大步数、最大 token 预算、最大墙钟时间、连续重复动作检测,以及一个显式的 finish 工具。
模型想结束,必须调用 finish 并给出结构化的结论,而不是在文本里说一句「我认为任务已经完成」就退出。这一个小设计,能省掉后面无数次的排查——因为「有没有结束」变成了一个可观测的事件,而不是一个需要揣摩的意图。
三、工具调用:真正决定成败的地方
如果只能保留一条建议,我会说:把八成的调试精力花在工具定义上。这不是夸张。在我参与过的项目里,工具描述的改进带来的成功率提升,几乎总是大于换一个更强的模型。
3.1 工具是一份接口契约,不是一句提示词
新手最容易犯的错,是把工具描述当成给模型的「提示」,随手写一句「查询数据库」就指望它会用。它不是不会用,它是不知道边界在哪里。
一份合格的工具体验,至少要说清楚五件事。
第一,它做什么,以及它不做什么。搜索工具要写明它搜的是内部知识库还是公网、覆盖什么时间范围、对哪类问题没有覆盖。我见过最典型的翻车是:搜索工具没说明它的索引只更新到几年前,模型拿它去查最新的政策变化,然后自信地引用了一份早已被修订的旧文档。它没有骗人,它只是不知道自己的视野有多窄。
第二,参数的语义与约束。不要只写 query: string,要写清楚「自然语言查询,建议不超过三十个字,尽量包含具体的实体名或时间范围」。参数名本身也要有信息量,q 和 query_text_with_time_range 给模型的心理暗示完全不同。
第三,返回结构的形状。模型需要知道会拿到什么,尤其是出错时会拿到什么。是空列表,还是带 error 字段的对象?这两种返回会让模型做出完全不同的下一步。
第四,成本与延迟特征。这一点常被忽略,但它直接决定了模型会不会滥用某个工具。如果你告诉它「这个工具平均耗时三十秒,且按调用次数计费」,它自然会倾向于先做便宜的筛选,再决定要不要打这一枪。
第五,失败模式与重试建议。超时了怎么办?查无结果怎么办?是换个关键词重试,还是换一个工具,还是直接向用户澄清?把这些写清楚,模型在遇到挫折时就不会瞎猜。
3.2 错误要「翻译」给模型听
工具抛出的异常,绝对不要原样丢回给模型。一段 Python traceback 对模型来说近似噪声,它要么忽略,要么基于几个关键词做出莫名其妙的反应。
正确的做法是写一个薄薄的适配层,把异常翻译成一段结构化的、模型能理解的自然语言说明:发生了什么、可能的原因、建议的下一步。
比如超时,返回的应该是「查询超时,可能是筛选条件过宽导致扫描数据量过大,建议缩小时间范围或增加过滤条件后重试」。这比一个光秃秃的 TimeoutError 有用一百倍。
这个适配层还有一个隐性价值:它逼着你去梳理每一个工具的真实失败模式。很多时候你会在写翻译文案的时候突然意识到,这个工具的错误处理本身就有问题。
3.3 按副作用给工具分级
我把工具分成三类,用不同的策略对待。
只读类:随便调,可以并行,可以批量,出错重试几乎无成本。这类工具值得鼓励模型多用,因为信息越多,判断越准。
写入类:会改变外部状态,比如发邮件、改数据库、下单、提交工单。这类工具必须走确认流程——要么在执行前让模型再检查一遍参数,要么把动作降级成「草稿」,由人来最终确认。
昂贵类:花钱或者耗时很长的。这类工具我会设配额,并且在描述里明示成本,让模型自己权衡。
这个分级不是为了安全合规走过场,它实实在在减少过线上事故。我至今记得一次测试环境里的惊魂时刻:模型为了「验证一下自己理解得对不对」,连续调用了一个发短信的工具十几次。那次之后,我把所有写入类工具的签名都加上了显式的确认参数,并且把「验证」这个动机从提示词里彻底删掉了。
3.4 工具太多怎么办
当工具数量超过二十个,模型的选错率会明显上升。这时候有两个办法。
一是分组:先把工具按领域分到几个包里,让模型先选包再选工具,相当于加了一层路由。这层路由可以很轻,甚至用关键词匹配就够了,不一定非得上模型。
二是检索式工具选择:把工具描述做成可检索的索引,每一步只把当前子目标相关的那几个工具塞进提示词。这在工具库有几十上百个的场景下几乎是必选项。
还有一个我特别喜欢的手法:把代码执行当成元工具。与其为每一个数据处理需求写一个专用工具,不如给一个受控的代码沙箱,让模型自己写几行代码。工具的爆炸式增长立刻被压下去了,而且模型的灵活性反而更高。当然,沙箱的隔离、资源限制和网络访问策略必须做扎实,这不是可以省事的地方。
四、记忆:不是把所有东西都塞进上下文
4.1 先分清四种记忆
一提到 Agent 的记忆,很多人的第一反应是「接个向量库」。但这只是四种记忆里的一种。
工作记忆:当前这一步决策所需的全部信息,包括当前子目标、最近几步的观察结果、相关的用户偏好。它的特点是容量有限、变动频繁,本质上就是你这一轮要装配进上下文的那点东西。
情景记忆:发生过什么。用户说过什么、Agent 做过什么、结果如何、哪一步失败了。它是一条按时间排布的事件流。
语义记忆:知道什么。事实、概念、文档、实体之间的关系。
程序性记忆:会做什么。工具定义、提示词、技能模板,以及从过去成功案例中提炼出来的做法。
大部分「Agent 记不住东西」的抱怨,其实是把四种记忆混成了一团。用户上周说过的偏好,应该进语义记忆;昨天那次失败的工具调用,应该进情景记忆;而「遇到这类任务先查 A 再查 B」这种经验,应该沉到程序性记忆里,变成提示词的一部分。混在一起存、混在一起取,结果就是既记不住,又取不准。
4.2 短期记忆:光有摘要远远不够
最流行的短期记忆方案是滚动窗口加摘要:超过阈值就把早期对话压缩成一段摘要。它简单,但丢信息丢得厉害——数字、日期、ID 这些精确的东西,一压缩就变形,而恰恰是这些东西在后面的步骤里最要命。
我的做法是摘要之外再加一组结构化状态槽。所谓状态槽,就是一份显式定义的键值对:当前目标、已确认的约束条件、关键实体及其属性、待办清单、已经排除的假设。摘要负责保留叙事脉络,状态槽负责保留精确事实。
每一次循环结束时,让模型用结构化输出更新状态槽,比让它写一段总结可靠得多。因为结构化输出是可以校验的——字段缺失、类型不对、引用了不存在的实体,都能在写回之前拦住。
状态槽还有一个隐性好处:它让调试变得可能。出问题时你打开状态看一看,就知道模型是哪一步把某个关键约束搞丢的。没有状态槽,你只能在一大段对话历史里大海捞针。
4.3 长期记忆:写入比检索难
向量检索的技术已经很成熟,难的是什么时候写、写什么、什么时候忘。
我的写入策略是这样的。
首先,不是所有对话都值得记。我会用一个轻量的打分环节,判断这段信息是否有长期价值——是不是关于用户的稳定偏好、是不是一个可复用的事实、是不是一次值得记住的失败教训。分数低的直接不进长期存储。这个环节可以很小,哪怕只用几条规则加一次简单的模型调用,也好过什么都不做。
其次,写入前先去重和合并。用户说「我在北京」和「我搬到上海了」,不应该变成两条并列的事实,而应该更新同一条实体记忆,并给旧的加一个失效标记。这就是为什么我现在更倾向于向量库加一张实体表的组合:向量负责模糊召回,实体表负责保证一致性。只靠向量,你永远不知道哪条是过期的。
第三,遗忘机制必须有。一个只增不减的记忆库,半年后检索质量会肉眼可见地下降,因为过期的、错误的、已经被推翻的信息全都在里面和正确信息竞争。我用的是组合拳:给每条记忆打时间戳和置信度,按时间做指数衰减排序;同时用 TTL 清理临时性信息;再定期跑一次合并任务,把语义重复的条目归并掉。
4.4 检索的关键是「按当前子目标」
一个常见的反模式是:每一轮都把召回的记忆一股脑塞进上下文。结果是上下文被塞满,模型的注意力被分散,反而更容易跑偏。
正确的做法是以当前子目标为查询条件去检索。当前要做的是「确认用户所在城市的社保政策」,那就只召回跟城市和政策相关的记忆,其他的一律不进来。
这也意味着,计划里子目标的描述质量,直接决定了记忆检索的质量——这两件事是耦合的。一个写得含糊的子目标,会召回一堆含糊的记忆,然后导向一个含糊的动作。所以你会看到,规划、工具、记忆这三块并不是三个独立模块,它们是同一套约束在不同环节的投影。
五、把三块拼起来:一个最小骨架
说了这么多,落到代码上其实很朴素。我用伪代码描述一下我常用的骨架结构,重点是分工:哪些是代码的职责,哪些交给模型。
python class Agent: def init(self, tools, memory, planner): self.tools = Registry(tools) # 工具注册表 + 参数校验 + 错误翻译 self.memory = memory # 四层记忆的统一门面 self.planner = planner # 规划器:出计划、重规划
def run(self, goal, budget):
state = State(goal=goal, slots={})
plan = self.planner.make_plan(goal, self.memory.recall(goal))
while not budget.exhausted():
subtask = plan.current()
ctx = self.memory.build_context(subtask, state) # 按子目标装配
action = llm_decide(ctx, self.tools.schemas(subtask))
if action.is_finish:
return self.verify_and_answer(state, action)
result = self.tools.invoke(action.name, action.args)
state.record(action, result) # 写回状态
self.memory.update(state, subtask, action, result)
if self.verify(subtask, state):
plan.advance()
else:
plan.replan_if_needed(state)
return self.fallback_answer(state)
几个设计要点值得展开说。
上下文装配是独立的一层。不要在各个地方随手往 messages 里 append,而要有一个统一的地方决定「这一步该看见什么」。这样做的好处是,你可以给它加预算控制、加优先级排序、加缓存,而且出问题时只需要在一个地方排查。
工具调用要有 schema 校验。模型生成的参数不一定合法。先在代码层拦一道,把校验失败的说明翻译成模型能懂的话退回去,比让它带着坏参数去执行要安全得多,也便宜得多。
状态是唯一真相。不要把关键信息只存在消息历史里。历史会被裁剪、会被摘要,而状态槽不会。任何时候,只要状态槽还在,你就能重建当前的处境。
验证要尽量自动化。子目标的验收标准能在代码里检查的,就别交给模型判断。能查数据库的就查数据库,能跑测试的就跑测试,能做断言的做断言,模型只负责那些确实无法自动化的判断。这一条能省下的成本,往往超出预期。
六、评估与可观测性:看不见的 Agent 改不动
Agent 的调试和传统软件最大的区别在于,它的失败往往是「轨迹」上的失败,而不是某一行的失败。所以你需要把整条轨迹记录下来:每一步的输入上下文、模型的输出、工具调用的参数与结果、状态槽的变化、耗时与 token 消耗。
在此之上,我会维护三样东西。
一个离线的评测集。二三十个有代表性的任务,覆盖成功路径和各种失败路径,每次改动后全跑一遍。别小看这个数字,二十个精心挑选的用例,能拦下绝大多数回归。它不需要一开始就完美,重要的是先有,然后每周往里加。
一组核心指标。任务成功率只是最表层的一个。我更关注:平均步数、重规划次数、工具错误率、上下文占用峰值、单位任务成本。这些指标往往比成功率更早暴露问题——等到成功率开始下降,通常已经是最后一步了。
一套自动加人工的判定。LLM-as-judge 可以覆盖大部分判分需求,但它对「看起来合理其实错了」的情况很不敏感。我的做法是让它先粗筛,把边界样本挑出来人工看。每周抽几十条轨迹认真读一遍,比看一百张仪表盘都管用。
七、我踩过的坑,以及一些反直觉的结论
最后说几条经验,有些和直觉是相反的。
别急着上多智能体。把一个任务拆给多个角色扮演的 Agent,看起来很优雅,但通信开销、状态同步、责任边界这些问题会迅速吃掉你所有的收益。我见过的大多数所谓多智能体系统,最后都退化成了一个带工具的循环加上几次提示词分工。先用单 Agent 把手写循环打磨好,真的不够用了再拆。
提示词越长不代表越好。很多人喜欢把所有的规则、示例、注意事项全塞进系统提示词。结果就是模型对每一条的注意力都被稀释了。我更倾向于把规则拆到它该在的地方:工具的约束写在工具描述里,计划的要求写在计划模板里,反思的标准写在反思提示词里。让信息在需要它的时候才出现。
把不确定性留在该留的地方。有些步骤适合让模型自由发挥,比如从一段杂乱文本里抽取实体;有些步骤必须确定,比如状态更新、预算扣减、终止判断。前者交给模型,后者写在代码里。混在一起的系统,调试起来是地狱。
延迟和成本是产品的一部分。一个成功率高出五个百分点但慢三倍的 Agent,在很多场景下就是不划算的。从第一天起就把 token 和耗时当作一等指标来优化,而不是等上线了再补。
八、写在最后
回头看这几年做 Agent 的经历,我最大的感受是:它更像是在做分布式系统的编排,而不是在做提示词工程。
模型是那个不可靠但极有能力的远程服务;工具是分布在各处的、有副作用、会失败的接口;记忆是一套需要一致性保证的存储;而规划,本质上是在一个不确定的环境里做任务调度。你会发现,那些在传统后端和系统设计里被反复验证过的原则——契约清晰、状态显式、错误可翻译、边界可验证、一切可观测——在这里一样适用,甚至更重要。
所以如果你现在正准备动手做一个 Agent,我的建议很简单:别从框架开始,从一个 while 循环开始。先让它老老实实完成一件小事,再逐步加上计划、工具和记忆。每加一层,都问自己一个问题:这一层是在减少不确定性,还是在增加不确定性?答案通常是清楚的。
Agent 的能力上限由模型决定,但它能不能落地,由你写下的那些看起来平淡无奇的胶水代码决定。而这恰恰是它好玩的地方。