网上讲大模型应用开发的资料,绝大多数都停留在“调接口、接对话”这一步。可真到了想做一个能独立干活的智能体(Agent),你会发现光会调用模型远远不够:它需要判断什么时候去查资料、什么时候做计算、跑偏了怎么拉回来、任务完成了怎么停下来。最近我把《动手学大模型智能体》这套理论加代码并重的Agent教程完整刷了一遍,过程里最大的感受是:这套东西是真的能把人从“会写单轮对话”带到“能搭出一个完整智能体”的。这篇文章就把我从这套教程里提炼出的核心知识,以及从“Demo跑通”到“能放心用”之间踩过的那些坑,一次性整理出来。适合已经写过一点模型调用代码、正在往智能体方向进阶的同学。
1. 为什么学了那么多大模型课,依然写不出一个Agent
1.1 只讲概念、只给Demo、只会调Prompt:多数课程的三宗罪
先说我观察到的现象。我身边有不少人,Transformer、RAG、思维链这些词都能聊,可一上手写智能体就卡壳。原因多半出在三件事上。
第一,只讲概念。Agent是什么、规划是什么、工具是什么,这些定义背得滚瓜烂熟,但没有一个能落地的例子。概念本身不等于能力,尤其对于工程味很重的智能体来说,不知道消息循环里每一步的数据长什么样,概念就没法转化。
第二,只给黑盒Demo。不少资料直接拿开源框架跑一个Demo,几行代码启动一个Agent,跑通了就万事大吉。问题是里面到底发生了什么,你完全不知道。用户问了一句,模型凭什么决定调用这个工具?调完工具以后结果怎么塞回上下文?这些关键路径被框架封装得严严实实,你的脑子还是空的。
第三,把Prompt当成全部。模型能力固然重要,但智能体是一个系统。Prompt只是系统里“思考模块”的初始化配置,还有工具接入、状态管理、循环终止、异常恢复这些工程问题,一样都不能少。只盯着Prompt,就像只研究大脑不研究手脚和神经,做出来的东西自然跑不动。
这三点背后有一个共同的根源:把“模型能力”当作了“智能体能力”。拆开这个问题,Agent的轮廓才清晰。
1.2 单轮问答是函数,Agent是一个循环
普通的大模型问答是一个无状态映射:输入一段文本,输出一段文本。你问它“1+1等于几”,它给你“2”,完事。中间没有任何外部动作,也不需要记住前几次对话。
Agent则完全不同。它本质上是一个循环控制系统,里面嵌着一个大模型作为决策器。整个过程可以抽象成四步:
- 模型根据当前状态,思考下一步该做什么;
- 如果要查信息、算数据、调接口,就声明一个工具调用;
- 系统负责把工具真正执行一遍,把结果当作新观察喂给模型;
- 重复这个过程,直到模型给出最终答案。
打个比方,单轮问答像你在地铁口问路,路人只负责给你指个方向;Agent像你雇了个跑腿的人,他不仅听懂你要去哪儿,还要自己查班次、看地图、换乘,最后到了目的地还要主动确认“到了”。指路的人只需要会说,跑腿的人得有一个完整的行动循环。
这个区别看起来简单,但很多人就是被这个“循环”卡住的。只要没亲手写过一遍消息如何在这些阶段之间流转,你对Agent的理解就永远隔着一层。
1.3 这套教程把理论到代码的距离压得极短
《动手学大模型智能体》在这方面解决得很直接:每一章先讲清楚一个组件的理论,比如工具调用为什么用结构化形式、记忆为什么分短期和长期,接着立刻给出最小可运行的代码片段。你不需要先把整本书看完才动手,而是读完一小节就能改代码、跑实验。
教程最后还会把前面各章写好的模块拼成一个完整项目,相当于一次集成演练。这个编排思路我很认同,因为智能体的知识点之间是强耦合的:你不理解工具调用,就调试不了循环;你不理解循环,就设计不了记忆方案。把一个完整项目拆成小块逐章攻克,再合并起来,是“学完就能用”的关键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一个最小可用Agent的四个组成件
2.1 大模型并不是Agent本身
这是最容易被误解的地方。很多人觉得“用一个大模型,加一句'你是帮我干活的助手',就是Agent了”。其实模型在其中只扮演两个角色:理解当前状态和决定下一步动作。
它不负责执行动作。模型没法替你查数据库,也没法真的调用付款接口,它能做的只是输出一段叫做“工具调用”的结构化文本。至于这段文本有没有被执行、执行结果如何,都是系统层面的事。
这种分工决定了工程上的基本思路:不要指望模型一次就给出完美答案,而是要把每个决策点做得足够清晰,让模型每一步只需要做出一个靠谱的小决定。模型就好比方向盘,真正让车动起来的是引擎、轮胎和刹车,他们配合得当,车才会跑。
2.2 工具是Agent与真实世界的接口
工具是一个Agent能不能“干活”的分水岭。没有工具的模型只能凭训练记忆回答,有了工具它才能获取实时天气、查询数据库、执行代码、操作第三方服务。
对工具的定义不是一个函数那么简单,关键是让模型知道这个工具是干什么的、需要哪些参数。实操里,每个工具都要配一份JSON Schema,描述函数名、参数名、参数类型、是否必填、以及一句说明。别小看这份说明,模型选择工具时真的会认真读它。
我见过一个很典型的例子:工具说明里写“查询某地区天气”,但参数名是area,模型就是会猜成city、location,然后报参数校验失败。所以参数命名和描述要尽可能贴近模型的自然语言习惯,这就是工程细节。
2.3 记忆决定了Agent是不是“聊过就忘”
智能体做到一定深度后,记忆绕不开。最少要有两类:
第一类是上下文窗口内的短期记忆,即把之前的对话历史、工具执行结果继续塞给模型。这是所有Agent的基础。
第二类是长期记忆,常见做法是把历史对话或知识切块后做向量化,存进一个向量检索库,需要时取出相关片段放入上下文。这套教程里对记忆的拆解很实用:先把短期记忆的维护做对,再加上长期记忆才顺理成章。
我自己的体会是,短期记忆的维护远没有想象中简单。你不能直接把所有历史都堆给模型,窗口很快就爆了;也不能只留最后几轮,因为工具的结果可能跨越很多轮。合理的做法是给消息加“重要度”或“截止时间”之类的过滤规则,这是教程代码里一笔带过但实际工作量很大的部分。
2.4 规划循环是Agent的发动机
最后一个组件是循环本身,也就是把“思考-行动-观察”串起来的主循环。如果你听过“ReAct”这个思路,它就是这套机制的代表:模型先产出推理过程,再决定动作,系统执行动作后把观察结果还给模型,模型再继续推理。
这个循环有几个必须回答的工程问题:最多跑几步?同一工具反复调用怎么办?模型连续两次给出不同答案时听谁的?这些都属于“规划循环”的细节,也是调试Agent时最费时间的地方。后面我会用一段完整代码演示这个循环到底是怎么写的。
3. 动手实现:一个“查天气+算数”的双工具Agent
这一节是全文的干货核心。我按教程的思路,带大家从空白文件夹开始,写一个真正能跑通的小型Agent。它只有两个工具,但完整覆盖了工具定义、模型调用、消息循环、终止条件这些全部要点。
3.1 环境准备与模型服务配置
先准备环境。建议用Python 3.10及以上版本,建一个干净的虚拟环境,避免和其他项目依赖冲突。本案例不引入任何第三方框架,只用标准库和HTTP请求,这样你可以清楚看到Agent的每一根骨架。
需要准备三项配置:模型服务的HTTP接口地址、API密钥、模型名称。把这几个值写进环境变量,而不是硬编码在代码里,避免密钥泄入版本库。
bash复制python3 -m venv agent_env
source agent_env/bin/activate
pip install -U pip
模型服务配置示例,放入.env文件:
bash复制MODEL_ENDPOINT=http://你的模型服务地址/v1/chat/completions
MODEL_API_KEY=你的密钥
MODEL_NAME=你的模型名
我在实际项目中通常还会额外配置超时时间和重试次数,这里先保持最简单,后面第4节再说踩坑细节。
3.2 工具定义:函数加JSON Schema
我先写两个工具函数。
第一个是天气查询,为了演示方便,我用一个静态字典模拟真实数据源。真实场景里替换成天气服务的HTTP调用即可,核心逻辑不变。
python复制def get_weather(city: str) -> str:
city = city.strip()
# 模拟数据,替换成真实接口这里才是接入点
fake_db = {
"cityA": "晴,24~32℃",
"cityB": "多云,26~34℃",
"cityC": "雷阵雨,25~31℃",
}
return fake_db.get(city, f"暂无{city}的实时天气数据,请稍后再试")
第二个是计算器。安全起见,不能直接把用户输入丢给eval,而是先用Python的AST模块做一遍语法白名单检查,只允许基础算术计算。
python复制import ast
def calc(expression: str) -> str:
allowed = (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.Pow, ast.Mod)
try:
node = ast.parse(expression, mode="eval")
except SyntaxError:
return "表达式格式错误"
for n in ast.walk(node):
if not isinstance(n, allowed):
return "表达式包含不支持的语法"
try:
return str(eval(expression, {"__builtins__": {}}, {}))
except Exception as e:
return f"计算出错: {e}"
接下来为每个工具写一份JSON Schema,这是模型“知道有这个工具可用”的关键。
python复制TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的实时天气情况,参数是城市名称,例如 cityA",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"}
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "calc",
"description": "执行一个数学表达式计算,支持加减乘除、乘方、取模",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "要计算的数学表达式"}
},
"required": ["expression"],
},
},
},
]
这里我踩过一个坑:description 写得越宽泛,模型越容易乱传参数。比如天气工具如果写“查询天气”,模型可能把“city”参数填成“城市A的天气”这种完整句子。后来我把description改成“参数是城市名称,例如 cityA”,错误率明显下降。工具说明要具体到“填什么、怎么填”。
3.3 封装模型调用:让模型学会“申请调用工具”
工具定义好之后,把模型调用封装成一个函数。这个函数接收当前的消息列表和工具列表,请求模型服务并返回模型给出的消息对象。
python复制import json
import os
import urllib.request
MODEL_ENDPOINT = os.getenv("MODEL_ENDPOINT")
MODEL_API_KEY = os.getenv("MODEL_API_KEY")
MODEL_NAME = os.getenv("MODEL_NAME")
def call_model(messages, tools=None):
payload = {
"model": MODEL_NAME,
"messages": messages,
"temperature": 0.2,
}
if tools:
payload["tools"] = tools
req = urllib.request.Request(
MODEL_ENDPOINT,
data=json.dumps(payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {MODEL_API_KEY}",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data["choices"][0]["message"]
注意temperature设成了0.2,不是0,也不是1。我的经验是,完全为0可能让模型在解析工具参数时过于机械,太高又容易编造工具调用。0.1到0.3之间对多数模型稳定性最好。
模型返回的消息对象里可能会带一个tool_calls字段,表示模型“想调用工具”。每个调用包含函数的名称和参数JSON字符串,后面主循环会解析它们。
3.4 ReAct主循环:思考、行动、观察
核心主循环不长,但逻辑必须严密。
python复制def run_tool(name, args):
if name == "get_weather":
return get_weather(args.get("city", ""))
if name == "calc":
return calc(args.get("expression", ""))
return f"未找到工具: {name}"
def run_agent(user_input, max_steps=6):
messages = [
{"role": "system",
"content": "你是能调用工具的助手。需要信息时调用工具,得到结果后继续推理。"
"完成用户请求后,直接给出最终答案,不要再次调用工具。"},
{"role": "user", "content": user_input},
]
for step in range(max_steps):
# 1. 思考:让模型决定下一步
reply = call_model(messages, tools=TOOLS)
messages.append(reply)
# 2. 如果模型没有要求调用工具,说明它认为可以结束了
tool_calls = reply.get("tool_calls")
if not tool_calls:
return reply.get("content", "")
# 3. 行动:逐个执行模型要求的工具调用
for call in tool_calls:
fn = call["function"]
name = fn["name"]
try:
args = json.loads(fn["arguments"] or "{}")
except json.JSONDecodeError:
args = {}
result = run_tool(name, args)
print(f"[step {step}] 调用工具 {name} 参数 {args} -> {result}")
# 4. 观察:把工具执行结果放回消息列表
messages.append({
"role": "tool",
"tool_call_id": call.get("id"),
"content": str(result),
})
return f"达到最大步数 {max_steps},任务未完成。"
这个循环的过程很清楚:
- 先把系统提示和用户问题放进消息列表;
- 调用模型,把模型的回复追加进消息列表;
- 如果回复里没有
tool_calls,说明模型准备直接回答,循环结束; - 如果有工具调用,就逐个执行,并把结果以
role: "tool"的身份追加回去; - 下一轮模型就能看到工具的结果,继续思考或最终作答。
这里最关键的一点是:工具的观察结果必须和上一次模型回复保持在同一个对话里,模型才能建立“我调用了工具、拿到了结果”的因果链条。这个顺序一旦错了,模型会一头雾水。
3.5 跑起来:两个测试用例的完整流程
主循环写好,测试两条用例。
python复制print(run_agent("帮我看看cityA现在天气怎么样?"))
print("----分割线----")
print(run_agent("帮我计算 123 * 456 + 1 等于多少,算出结果后直接告诉我。"))
第一条用例的运行过程应该是:模型先发出get_weather调用,系统执行后把“晴,24~32℃”的内容放回对话,模型接着给出最终回答。第二条用例则只需要一次计算工具调用,然后直接汇报结果。
到此你已经拥有一个完整的单Agent骨架。别看它简单,换成任何真实工具都能跑通。教程里反复强调的“最小可运行”,就是让你先拥有一个能改的起点,而不是拿着一堆概念发呆。
4. 从“能跑”到“能用”,我踩过的坑都在这
4.1 模型不输出结构化工具调用怎么办
第一个坑来得很快:不是每次调用模型都会乖乖返回tool_calls。尤其在小模型上,你让它查天气,它可能在content里直接写“好的,我来查一下cityA的天气……”然后没了。这在日志里表现为:主循环走到if not tool_calls直接返回了这一句话。
排查这件事,我给自己的四步流程:
- 看日志里模型返回的原始消息,确认是不是有
tool_calls但被自己的代码漏解析了; - 检查工具列表是否真的传进去了,有的封装会把
tools参数漏掉; - 把模型回复打印出来,判断它是不会用工具,还是不会说“我正在调用工具”;
- 如果是模型能力问题,最简单的办法是在系统提示里加一句:“当你可以通过工具获取信息时,必须返回一个tool_calls,而不是直接描述你打算做什么。”
实测下来,最后这一句对很多模型都有效。实在不行,可以做一个兜底:检测到模型没有工具调用、但回复里出现了“我查一下”“我看看”这样的字眼,就把这段话作为一条user消息塞回去,强制它走工具流程。虽然不那么优雅,但确实能救场。
4.2 工具参数解析被一个空格搞崩
第二个坑发生在参数解析阶段。模型返回的arguments是一个JSON字符串,但模型的容错能力没那么好,有时候会多一个尾逗号,有时候字符串值带多余引号,甚至把数字参数写成“三”这种中文数字。
我在JSON解析处做过防御:
python复制try:
args = json.loads(fn["arguments"] or "{}")
except json.JSONDecodeError:
# 尝试修复常见问题:去除尾逗号
cleaned = fn["arguments"].strip()
if cleaned.endswith(","):
cleaned = cleaned[:-1]
try:
args = json.loads(cleaned or "{}")
except json.JSONDecodeError:
args = {}
这个兜底不完美,但能让大多数偶发问题不直接崩溃。更重要的经验是:工具函数本身要对参数做类型转换和范围校验。比如calc的表达式一开始可能被模型带着引号传进来,你需要在run_tool里先剥离。
4.3 循环永远不停和上下文爆炸
第三个坑最恶心:Agent会陷入死循环,同一个工具被反复调用,或者模型对同一个工具调用的参数每次变一点点,永远不结束。
我总结出三个应对手段:
第一,硬性限制max_steps,上面的代码已经做了。这是底线,必须留。
第二,检测重复调用。维护一个列表,记录每个步骤的名字和参数,如果同一个工具加同一组参数出现了两次以上,直接中止循环,返回当前信息让用户确认。
第三,控制工具返回内容的长度。最初我的天气工具会返回一整段未来七天的预报文本,等用户连续问了好几个城市,消息列表被撑爆,模型反而开始忽略后面的历史。后来我把工具输出一律压缩成“摘要式”文本,比如只返回温度范围和天气类型,问题立刻缓解。
上下文膨胀不只是“内容太多”,还会影响模型对早期意图的判断。维护多轮上下文时,我一般保留最近的3到5轮完整消息,更早的消息压缩成摘要。这个经验在教程的多轮对话章里也有强化。
4.4 让效果回归到固定测试集
做Agent最怕“改了一个prompt,另一个场景悄悄变差”。我强烈建议从第一天就给Agent准备一组固定测试用例,至少包含正常请求、缺少参数请求、多工具并行请求、超长上下文请求这四类。
每改动一次代码,就连着跑三遍测试集。为什么是三遍?因为模型有随机性,即使temperature设为0,不同服务、不同批次也会有微小波动。三遍里至少两遍成功,我才认为这个改动是有效的。
我自己吃过这个亏:调了一个“让模型更积极主动”的prompt,结果测试用例成功率从80%降到50%,那种场景不跑回归根本发现不了。把测试集固化下来,是Agent工程质量的分水岭。
5. 单Agent稳定之后,多Agent协作怎么入手
5.1 为什么不建议一上来就做多个Agent
现在网上聊得最多的都是“多Agent协作”,好像不拆几个角色就落伍了。我把这段话放在前面:如果你的任务一个Agent加三五个工具就能完成,不要拆多Agent。多Agent带来的通信开销、状态一致性问题,会把你前期调单Agent省下的时间全部吃掉。
什么时候该拆?通常是任务链太长,单Agent的prompt里塞了太多职责,模型频繁把“查资料”和“写报告”两种动作混在一起,或者工具数量多到模型选择混乱。这时候拆分才值得。
5.2 两个Agent说话,先谈好消息格式
多Agent本质上是把消息循环从“模型-工具”扩展成“模型-模型”。两个Agent之间要协作,第一步不是写代码,而是谈好它们之间的消息格式。
我常用的模式是定义一个统一的消息字典:
python复制{
"sender": "analyst",
"receiver": "writer",
"task": "section1",
"content": "……",
"status": "done",
}
每个Agent只认识这个格式。如果A的输出格式不稳定,B的解析就会崩。所以多Agent的第一个工程约定一定是协议先行,而不是自由发挥。
5.3 一个三Agent协作写报告的框架
拿“基于数据写分析报告”这个场景来说,可以拆成三个角色:
- 数据Agent:负责检索数据和跑工具,输出结构化统计结果;
- 分析Agent:根据统计结果归纳结论,输出要点;
- 写作Agent:把要点扩展成通顺报告,做最终润色。
它们的协作方式是最简单的流水线:数据Agent先干,产出交给分析Agent,分析Agent得出结论,最后写作Agent成稿。每个Agent的prompt都短而专注,工具列表也小,稳定度反而上来了。
流水线跑通后再考虑更复杂的动态路由:一个协调Agent来调度下一个该谁上场。但那只适用于真正复杂的场景,大多数项目流水线已经足够。
5.4 复杂度是按指数涨的,记得留“日志总开关”
多Agent最大的敌人是调试。三个Agent互相传消息,出了问题你根本不知道是哪一步弄丢了字段。所以从第一次跑多Agent开始,就要给每个消息打上session_id和step,并留一个“打印所有Agent间消息”的开关,日常关掉,调试时打开。
我见过不少同学多Agent一崩就怀疑模型不行,其实把日志拉出来一看,往往是A传给B的字段在某个分支被覆盖了。先看日志,再怀疑模型,这个习惯能省很多时间。
6. 教程刷完之后的下一步
刷完《动手学大模型智能体》,不代表你已经是Agent专家了,但它给了你一条正确的起步路径。我给想继续往深走的朋友留三个方向。
6.1 第一批练习:把自己的真实工具接进来
教程里的工具都是为教学设计的,真正让你成长的是把工具替换成你自己的业务接口。我从最简单的开始做起:一个Agent查自家数据库里的订单状态,另一个Agent对接日历接口做日程管理。每接一个真实工具,你都能复习一遍“工具描述怎么写才不被模型误解”这件事。
练习时记得保留第3节那个主循环的骨架,不要急着上重型框架。先亲手把两三个工具接稳,你再看那些框架,会发现它只是帮你管理了这部分循环而已,心里会很踏实。
6.2 第二批练习:加长期记忆和并发容错
第二个阶段,把向量化记忆接进来。核心是:每次对话结束后,把“用户问题+Agent回答+关键工具结果”存进向量库,下次用户问相关问题时,先把相似片段检索回来再拼进系统提示。
这一步最容易忽略的是“检索到的片段是否新鲜”。我给过自己一个教训:明明数据库里的库存已经更新了,Agent还是根据三天前的缓存回答“有货”。后来我在记忆片段上加了时间戳,检索结果出来后先过滤过期内容,问题才解决。
并发容错也不能拖太晚。真实环境里用户会同时发起很多请求,模型接口偶尔报错是常态。给外层调用加上超时重试和退避,给日志加上请求ID,这属于上线前的基本功,但在学习阶段很多人完全不会留意。
6.3 一个让我长期受益的收尾技巧
最后分享一个非常实用的小技巧,来自我在教程里反复验证的经验:在System Prompt里,给Agent规定一个固定的“完成标记”。比如要求模型在所有最终答案前必须加上“【完成】”前缀。
python复制"当你确定已经完成用户请求时,请在回答开头插入【完成】,然后再写最终内容。"
这个标记看起来有点呆,但它解决了一个大问题:程序判断循环是否终止,不再依赖“模型的content又长又格式乱”这种模糊条件,只要检测文本里有没有【完成】,就能可靠截断。配合前面的max_steps双保险,Agent很少再出现“自己以为完了、其实还在循环里”的尴尬情况。
我自己在好几个项目里都沿用这套写法,甚至在多Agent场景里,写作文案Agent输出“【完成】”后才会被主流程接收。简单、稳定、可迁移,比任何复杂的“是否结束”策略都好用。
做完这些练习之后,你手里的那个教学Agent基本就变成一个有模有样的生产级雏形了。下一步是继续加能力,还是换更复杂的编排框架,方向自然会清晰起来。学Agent这件事,最怕的是永远在概念里打转;这套教程至少给了我一个标准答案:先把手弄脏,跑通第一个循环,然后让它在真实任务里迭代。你也值得这样试试。
