做Agent开发,前几次我总觉得不过是写一个for循环反复调用模型接口,等真把一个支持查天气、算数据、读文档的多功能ReAct Agent跑通以后才发现,真正难的从来不是循环代码本身,而是怎么让模型“想清楚再动手”,怎么在工具返回一堆脏数据时还能稳住不崩。这篇文章就是我个人的完整实战复盘,从ReAct的思路拆解、工具设计、主循环实现,到排查“模型幻觉工具参数”“JSON反复解析失败”这些典型问题,全部拿出来分享。适合已经会调用大模型API、但对Agent框架还停留在概念阶段的开发者,也适合正准备给项目接入工具调用能力却不知道从哪下手的朋友。
1. 从“只会聊天”到“动手干活”:ReAct Agent到底在解决什么问题
1.1 先回到最原始的问题:为什么不能让模型直接回答“今天天气怎么样”
以前我们做聊天机器人,用户问一句“今天天气怎么样”,模型要么回答“我无法实时获取天气信息”,要么瞎编一个“晴天,24度”。这是因为大模型的训练数据有截止时间,它本身没有感知实时世界的能力,也没有执行计算和读取本地文件的能力。所以第一版产品如果想要天气功能,最粗暴的做法是把天气数据预先拼进Prompt里,让模型“看着数据回答”。这条路在单一功能、数据量小的时候还行,功能一多就出问题:比如用户又问“算一下明天比今天冷几度”,这需要先拿两天的天气数据,再做算术,再回复——全塞进一次Prompt,模型根本无法可靠地完成多步骤推理。
这也是引入ReAct的核心理由。ReAct是Reasoning和Acting的合写,思想相当朴素:让模型在解决问题的过程中,交替进行“推理”和“行动”。推理就是让模型用自然语言说出自己当前的分析和下一步计划,行动就是让模型调用外部工具获取信息或执行操作,工具执行完把结果返回给模型,模型再基于新信息继续推理,直到得出最终答案。本质上就是把人解决问题的思维过程搬到了模型身上。
这个过程在大模型术语里通常表现为一个循环:Thought(模型想什么)→ Action(模型调什么工具)→ Observation(工具返回什么结果)→ 再Thought……。每一轮模型都会输出一小段“思考过程”和一个结构化的工具调用请求。我们的Agent代码负责解析这个请求,找到对应工具,执行它,再把结果拼装成模型能理解的Observation,继续喂给模型。
1.2 ReAct的核心循环:Thought(想)— Action(动)— Observation(看)
用一个具体例子说明这个循环。
用户问:“上海今天多少度?比昨天低多少?”在一个完善的ReAct Agent里,调用链可能是这样的:
- 第一轮:模型收到用户问题,输出Thought:不知道上海今天的气温,需要先查天气;同时输出Action:调用
get_weather(city="上海", date="今天")。 - 我们后端执行这个工具,拿到“上海今天25度”的Observation。
- 第二轮:模型发现还缺昨天温度,输出Action:调用
get_weather(city="上海", date="昨天")。 - Observation返回“昨天31度”。
- 第三轮:模型不再调用工具,直接输出Thought:今天25度,昨天31度,相差6度;然后给出最终回答:“上海今天25度,比昨天低了6度”。
注意这里的关键:模型并不是一次性生成完整答案,而是每一次“看到Observation”后再决定下一步。这个“看到再决定”的机制,正是Agent能完成多步任务的核心。它把一个大问题拆成多个小步骤,每一步都有外部信息作为支撑,幻觉概率显著下降,也让我们能在中途介入、截断或修正。
我用“主脑+手脚”来理解它:大模型是主脑,负责思考、判断、拆解任务;工具是手脚,负责获取数据、执行动作。ReAct框架做的就是让主脑指挥手脚的一整套通讯协议。
2. 整体设计:一个主脑,三个工具,一套协议
2.1 功能边界与工具清单
开发之前先定边界,这个Agent就做三件事:查天气、算数据、读文档。听起来简单,但每个功能背后都有各自的坑。
查天气意味着Agent必须能处理“城市+时间”的组合,还要面对歧义:用户说“今天”是自然语言,工具接口往往需要的是YYYY-MM-DD格式;用户说“回家那边的天气”,模型并不知道“家”是哪,这就是开放问题,需要在Prompt里约束它向用户反问。
算数据意味着给Agent一个安全可控的“计算器”。模型本身做不了精确算术,尤其是大数字连乘、除不尽的场景,必须依赖外部计算工具。但又有安全考量,不能让模型端执行任意Python代码,风险太高。
读文档意味着Agent要能接收用户上传或指定的文本文件,从中查找信息并回答。这里最大的坑是“文档太长”。几十页的PDF全部塞进上下文,token费用和模型注意力都会出问题,所以必须做切片、筛选、分段摘要。
最终工具清单如下:
get_weather(city, date):输入城市名和日期,返回天气、气温、风力等结构化文本。calculate(expression_str):输入数学表达式字符串,返回精确计算结果。search_document(file_path, query):读取指定文档,检索与query相关的片段,返回关键信息。
有人会问:工具才三个,是不是太少了?我的想法是先跑通链路再加功能。Agent的复杂度和工具数量不是线性关系,而是指数级上升。工具一多,模型选错工具的概率会明显变大,排错成本也会翻倍。先把三个工具跑通,再往里面加“查数据库”“发邮件”“调用内部API”都会顺很多。
2.2 工具注册表与函数调用的Schema设计
工具不是写死在一个函数里的,而是以“注册表”的形式集中管理。给每个工具定义三样东西:名字、描述、参数Schema。
名字要短且见名知义,比如get_weather而不是fetch_weather_info_from_remote_api,长名字在模型输出时容易产生拼写截断。描述是指引模型“什么时候使用这个工具”的关键。描述写得越清楚,模型工具选择越准确。参数Schema对应工具需要的参数名、类型、是否必填、描述,这个描述同样重要。
我用一个字典来维护工具注册表,伪代码如下:
TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市在指定日期的天气情况。日期格式为YYYY-MM-DD,不传入日期时默认当天。", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如 上海、北京"}, "date": {"type": "string", "description": "日期,格式YYYY-MM-DD,可选"} }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate", "description": "进行数学计算,支持加减乘除、幂、括号。仅用于数值计算,不能用于其他问题。", "parameters": { "type": "object", "properties": { "expression_str": {"type": "string", "description": "数学表达式,如 (2+3)*5"} }, "required": ["expression_str"] } } }, { "type": "function", "function": { "name": "search_document", "description": "在指定的本地文档中检索与问题相关的信息。当用户要求读取文件、文档内容或需要依据文档回答时使用。", "parameters": { "type": "object", "properties": { "file_path": {"type": "string", "description": "文档路径"}, "query": {"type": "string", "description": "要在文档中检索的问题或关键词"} }, "required": ["file_path", "query"] } } } ]细心的同学会发现,工具描述里我特意写了“不传入日期时默认当天”“不能用于其他问题”。这些看似废话的约束,在实际调用里能显著降低模型的乱用概率。模型是根据描述来决定调哪个工具的,描述越精准,行为就越可控。
从协议层面说,这套注册表会被直接序列化进每次API请求里。当前主流的大模型服务基本都支持函数调用的一种做法:用户在请求里带上工具描述,当模型认为需要调用工具时,返回值里会包含结构化工具调用字段,而不是普通文本。我们的主循环就是围绕这个字段来工作的。
3. 核心工具逐一实现与原理拆解
3.1 查天气工具:最容易被忽略的“时间和地点歧义”
查天气工具本身不复杂,但做好一个“能被Agent稳定使用”的天气工具,比想象中麻烦。最大的问题不是API连接,而是数据的口径。
调用第三方天气接口时,通常需要经纬度或城市编码。但用户说“北京”或者“上海”,模型传入的参数可能长这样:city="北京"没问题,但如果是city="the capital of China"呢?模型完全有可能把口语化表达直接塞进去。所以天气工具内部必须做一层参数清洗:先对城市做匹配,如果匹配不上就尝试别名扩展,再不行就返回一个“城市无法识别”的标准化错误,而不应该直接抛异常。标准化错误返回给Agent后,模型自然会追问用户要更完整的城市名。
另外是日期。第三方天气接口通常只支持“未来几天预报”或“历史某天”,而且不同服务覆盖范围不一样。Agent需要“昨天”的温度,但免费接口往往拿不到历史数据。这时候工具内部可以做近似处理:比如从预报接口里拿“未来7天”,如果用户传的日期不在范围内,就明确返回“该日期不在可查询范围内”。绝不填充假数据。
我还给天气工具加了一个格式化输出的逻辑。原始API返回的是JSON,里面可能有几十个字段,全返回给模型既浪费token又干扰推理。所以工具最终只返回一句标准化文本:
上海 2025-01-15 天气:多云 气温:5~12℃ 湿度:65% 风力:东北风3级这比扔一堆JSON过去可靠得多。Agent需要的是信息,不是数据结构。工具返回值的设计原则就是“面向模型回答问题的思路来精简信息”。
3.2 算数据工具:不声不响但最容易翻车的一个
算数据看起来最简单,传个表达式进去,eval一下,返回结果。但直接裸奔eval(user_input)是要出大事的,因为用户或者模型可能传入__import__('os').system('rm -rf /')这类恶意表达式。所以安全是第一位。
我采用的是AST白名单方案:只允许expression,即ast.Expression;只允许节点类型:数字常量、四则运算、乘方、括号。任何函数调用、属性访问、导入、赋值操作直接拒绝。核心代码长这样:
import ast import operator ALLOWED_NODES = (ast.Expression, ast.Constant, ast.BinOp, ast.UnaryOp, ast.Pow) OPERATORS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod, ast.Pow: operator.pow, ast.USub: operator.neg, ast.UAdd: operator.pos, } def try_eval(expr_str): try: tree = ast.parse(expr_str, mode="eval") except SyntaxError: raise ValueError("表达式语法错误") if not isinstance(tree, ALLOWED_NODES): raise ValueError("仅支持数学表达式,不支持函数调用或赋值语句") for node in ast.walk(tree): if not isinstance(node, ALLOWED_NODES): raise ValueError("仅支持数学表达式,不支持函数调用或赋值语句") result = eval(compile(tree, filename="<expr>", mode="eval"), {"__builtins__": {}}, {}) return result原理基于Python的语法树解析。ast.parse做纯语法解析,宁可多走几遍ast.walk检查所有节点类型,也不要图省事。eval时把内置函数全部锁死,从根本上避免注入。
另一个坑是浮点精度。用户问“1/3等于多少”,直接返回0.3333333333333333虽然没错,但不好看;用户问“0.1+0.2”,返回0.30000000000000004会让人以为算错了。我的处理是:如果结果是浮点数,优先尝试用decimal精确表示,或做有效小数位处理。实际代码里我会用f"{result:.10f}".rstrip("0").rstrip(".")来整理输出,既保留必要精度,又不让尾数难看的数干扰模型判断。
还有一个细节:表达式太短太怪。比如模型把自然语言直接传进来,calculate(expression_str="the average of 3 and 5")——这类输入要明确报错“无法解析为数学表达式”,而不是返回空结果。错误信息也要返回给模型,方便它自我修正。
3.3 读文档工具:文本太长时的分片与裁剪策略
读文档是这个Agent里实现起来最繁琐的工具。文档有长有短,短的几百字可以一次性读完,长的可能有几十万Tokens,不可能全部塞进上下文。
我的方案分两个量级处理:
- 短文档(少于某个阈值,比如1万Tokens):全文读取,直接和用户问题一起返回给模型。
- 长文档:先切分成带固定大小的片段,找到与query最相关的一个或几个片段,只把片段提取出来返回。
切分不能按字符硬切,会切断语义。最简单的办法是按段落切,再根据token数合并,尽量保持段落完整。如果段落太长,再按句号、换行切。这里可以用大模型的分词器来估算token数,但没有分词器时按中文字符数估个大概也行,一般中文字符数 × 0.6 就能逼近token数。
对于“找片段”这一步,没有太好用的向量检索工具的话,可以先用BM25或者最简单的高频词打分:把query拆成关键词,统计每个片段里关键词出现的次数,按得分排序,返回前2~3个片段。这些片段之间留一个分隔符,避免模型串行。如果连检索都不想做,更粗暴的方案是把文档开头、结尾和包含关键词的部分各截取一段。比如用户问“文档里关于预算的部分是什么”,就只把“预算”附近的段落返回给Agent。
这种“不全文返回,只返回相关内容”的做法,让文档工具在真实场景中省掉大量Token开销。代价是检索不准时,Agent会看不到部分内容。所以我会刻意在工具说明里写上:如果确实需要完整背景才能回答,请标注文件路径,我们可以换用摘要模式。这在工程上其实是一组工具的组合,但当前版本先用一个工具做两种模式即可。
4. Agent主循环代码实现
4.1 函数调用协议与消息格式
现在到了最核心的部分:把三个工具和模型拼成一个Agent。主流模型服务商大多支持一种函数调用功能:请求时带tools,模型返回的内容里可能带tool_calls。
消息格式有一个固定套路。用户提问是一个user消息。模型每轮生成一个assistant消息,assistant消息可能带tool_calls;每个工具执行完,回填一个tool消息,并且要用tool_call_id明确指向它响应的那个调用。如果不把这个assistant消息单独加入历史,模型就无法建立“这个Observation对应的是我上一次调用的那个工具”的关联。
这个关联很关键。我见过很多新手把assistant消息漏掉了,只放user、tool结果、再让模型继续,结果模型完全不知道工具结果是谁触发的。构建消息时还要注意:每一次新的模型请求,都必须包含完整的消息历史,从最早的system到最新一次工具结果,一条不能丢。
4.2 主循环的完整实现
主循环逻辑可以浓缩成这样:
def run_agent(user_input, max_steps=5): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input} ] for step in range(max_steps): resp = call_llm(messages, tools=TOOLS) # 情况1:模型直接给了最终回答,没有任何函数调用 if not resp.get("tool_calls"): return resp["content"] # 情况2:模型决定调用一个或多个工具 assistant_msg = { "role": "assistant", "content": resp.get("content", ""), "tool_calls": resp["tool_calls"] } messages.append(assistant_msg) for tool_call in resp["tool_calls"]: fn_name = tool_call["function"]["name"] fn_args = json.loads(tool_call["function"]["arguments"]) result = dispatch_tool(fn_name, fn_args) messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": result }) return "已达最大步骤限制,未能完成请求。"这里有几个容易踩的细节。第一个是json.loads(tool_call["function"]["arguments"])。模型输出的arguments理论上是一个JSON字符串,但偶尔会带多余换行、截断、缺引号。我建议不要直接json.loads,而是先用一个容错函数处理:如果解析失败,尝试提取字符串里的JSON片段再解析;再失败就返回一条“参数格式错误”的tool消息,让模型重新输出参数,而不是让整个Agent崩溃。
第二个是dispatch_tool,它根据工具名找到注册表里的实现函数。找不到工具名时也要返回“不存在该工具”的tool消息,模型看到后会修正自己的选择。
第三个是最大循环次数。我设了max_steps=5,不是越大越好。循环越深,token消耗越大,而且模型在长时间循环中可能会开始循环调用同一个工具。5轮基本能覆盖绝大多数查天气、计算、读文档的任务组合。读文档拉长时可能要更多步,但宁可用“凑不够步骤就提示用户换种问法”来兜底。
主循环的system提示词也需要打磨。我的写法如下:
你是一个智能助手,可以调用工具来获取信息或执行计算。 使用工具时必须严格遵循以下流程: 1. 先思考当前任务是否需要调用工具,如果需要,输出包含工具调用的回复。 2. 第一次工具返回结果后,根据结果继续思考,直到信息完整再回答用户。 3. 如果用户的问题需要多个信息,优先用一次工具获取信息,不要重复调用同一个工具。 4. 只有在获得足够信息后才能给出最终答案。这里的关键是第3条。我的真实体会是:模型有时候会在一个循环里多次调用同一个工具,比如连续查三次“上海天气”。加一句“不要重复调用同一个工具”,能明显减少这种浪费。
5. 踩坑实录与排查技巧
5.1 典型问题、现象、原因和解决方案
我跑通这个Agent之后,又反复折腾了很久,把遇到的典型问题整理成一个速查表。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| Agent明明有天气工具,却凭空编天气数据 | 系统提示词里没强调“必须调用工具才能获取实时信息”,模型直接把可能的知识幻觉出来了 | 在system提示中明确“任何天气、计算、文档内容都必须通过工具获得,不能自行编造” |
工具参数出现city="Shanghai, China"这种超长描述 | 模型不理解参数应该短,或者没有正确理解示例 | 参数描述里加“只填城市名,不要把国家或省份放进来”,必要时在描述中给一个正面示例 |
| 模型解析工具结果失败,反复报错 | 工具返回内容过长或结构不清晰 | 精简工具返回内容;在返回内容前统一加“工具返回如下”前缀 |
| Agent陷入死循环,反复调用同一个工具 | 没有工具调用去重、没有最大步数 | 增加max_steps,同一轮内对相同工具+相同参数做一次去重,重复就提前终止并提示 |
| 表达式含非法代码时直接抛异常 | 代码调用eval太裸奔 | 用AST白名单过滤,把异常包装成标准错误文本返回给模型 |
| 读文档读到一半因为Token超限失败 | 文档全文太长直接进入上下文 | 切分+检索,只返回相关片段 |
| 计算器返回0.30000000000000004,模型误以为自己算错了 | 浮点数尾数问题 | 在工具层做格式化,把浮点结果保留有效小数再输出 |
这里面我认为最奇葩的是:“Agent有工具却还是编数据”。这是因为模型默认优先用自己的知识回答,如果用户问“北京天气”,它先不调用工具就直接输出答案。这时候不能在工具层解决,必须靠prompt约束。我甚至会在system里加一句:“如果你不确定答案,必须先调用工具确认,不能直接猜测。”这句话对天气、实时数据类的任务很管用。
5.2 排查手段与日志建议
Agent出了问题很难复现,因为模型输出有随机性。刚开始我靠一遍遍跑同一条问句来碰运气,后来发现正确做法是把整个循环的轨迹完整打印出来,每一步都保留。
我会在每次循环里输出四个字段:step编号、模型本轮输出(包含thought部分和tool_calls)、工具执行结果摘要、当前messages条数和累计token数。这样即使Agent最终答错了,也能清楚看到是“模型没调用工具”还是“工具结果被错误理解”。
日志打印示例:
[step 1] assistant: thought: 用户问上海今天温度,需要查天气。 tool_calls: get_weather(city="上海", date="2025-01-15") [step 1] tool result: 上海 2025-01-15 天气:多云 气温:5~12℃ 湿度:65% [step 2] assistant: 上海今天最高12℃,最低5℃,多云天气。只要保留这套日志,绝大多数问题都能定位。另一个排查技巧是给每个工具的执行加一个超时和异常捕获。比如天气接口挂了,不能让它抛异常导致Agent整个退出,而是返回“天气接口暂时不可用,请稍后再试”的tool消息。把后端异常全部变成模型可理解的正常文本,Agent健壮性一下子提高很多。
还有一个隐藏很深的坑:函数返回内容里带有换行符,模型有时候把换行理解成“两条不同的Observation”,导致推理混乱。解决方案是工具统一返回单行文本,必要时用分隔符而不是换行来区分多个信息块。比如返回多天天气时,用“ | ”分隔而不是换行。这是我实验后发现的典型问题,常规文档可不会提这个。
另外,我在参数校验上吃过亏。模型可能给date传入"2025/01/15"或"明天",工具做内部容错时最好尝试解析多种日期格式,解析失败就返回“日期格式应为YYYY-MM-DD”而不是直接报错。这样模型看到错误信息后能自我修正一次,往往下一轮就传对了。
调试时我还会用一个小技巧:故意用一个不存在的工具名get_weather_xxx测试Agent的容错能力,看它会不会用“不存在该工具”的反馈来自我修正。这能同时检验主循环和模型的纠错能力。
最后再分享一个思路层面的东西:如果Agent开发遇到长时间无法定位的问题,不要在里面无限加Prompt,先把循环打印出来读一遍,用“人脑模拟一遍模型看到的Observation”,绝大多数问题都会浮出水面。我自己在这个项目上最大的收获不是学会了写工具注册表,而是真正理解了模型输出的“不确定性”应该被当作一种下游要处理的常态,而不是一种意外。给工具返回、暴露的参数、错误信息都设计成模型“一看就懂”的样子,Agent整体稳定性就能上一个台阶。这个项目后续如果想扩展,可以在工具注册表里加新的工具,并在主循环里引入并行调用或人工确认环节——不过那又是另一个话题了。