
大概两年前我刚开始认真琢磨让大模型干活这件事的时候最大的挫败感不是模型不聪明而是它太闭门造车。你问它一句帮我查一下A市今天会不会下雨顺便告诉我适不适合跑步它能给你写出一篇声情并茂的回答但里面每一个今天和A市都是它编出来的。AI Agent 这个概念这几年火起来核心其实就是一句话让大模型不再只是输出文字而是通过 ReAct 架构去调用外部工具拿到真实世界的反馈再基于反馈继续思考、继续行动直到把一件事真正办完。这篇文章我不打算讲太多玄乎的理论而是从一个能跑的最小系统出发把 ReAct 架构、外部工具调用、接口设计、常见坑和工程化思路完整串一遍。适合刚接触 Agent 开发、想亲手把大模型从聊天推向办事的读者也适合已经在用各种框架、但想搞清楚底层循环逻辑的人。1. 模型的能力边界先把为什么必须接工具想透1.1 知识截止、私有数据与实时状态大模型的知识本质上都来自训练语料而语料是有截止日和覆盖范围的。更要命的不是不知道新消息而是有三类数据永远不可能出现在公开语料里企业内部的业务数据、某个具体用户的私有信息、以及系统的实时状态。无论模型多聪明它都无法猜出你数据库里今天新增了多少订单也无法推理出此刻 A 市的实时气温和湿度。所以看待 Agent 的第一个视角要调整模型不是真相的来源而是决策的来源。真相必须通过工具去获取——查数据库的工具、调业务接口的工具、读文件的工具。我一开始做 Agent 时犯过的最大错误就是指望模型知道而不是给它查的通道。后来把凡是涉及现状的问题全部路由到工具模型编造信息的比例立刻降了下来。这个认知是所有 Agent 设计的起点不把这一点想清楚后面写多少提示词都治标不治本。1.2 生成内容与执行任务之间隔着一条确定性鸿沟聊天只需要生成文本而办事意味着查询、计算、写入、调用、触发。模型擅长的是语义理解与生成并不擅长确定性计算。举个例子你让它心算 4783 × 926它可能在假装计算后给出一个看起来合理的答案可正确率并不高而一个 5 行的 Python 函数可以做到 100% 正确。这背后是一条很实用的分工原则所有确定性、可编程、可验证的操作一律外置给工具模型只负责两件事——判断当前该调用什么工具以及把工具返回的结果组织成用户能看懂的答复。这个分工直接影响工具接口的设计思路。你不需要一个什么都会的模型你需要一个知道去哪查的模型外加一组真能查到的工具。1.3 ReAct 在 Agent 体系里到底处于什么位置ReAct 是 Reasoning Acting 的组合最早由学术圈让模型边推理边行动的工作带火后来成为 Agent 社区最普及的基础模式。它的核心特点是行动与推理交替进行模型先表达当前的想法Thought然后决定调用某个工具Action工具返回结果Observation模型再基于结果继续思考直到给出最终答复Final Answer。ReAct 不是新的模型也不依赖特定厂商它只是用提示词或者结构化接口编排模型的决策流程。正因为这样它几乎适用于任何支持对话补全的大模型。这也是我推荐把 ReAct 作为 Agent 入门第一课的原因——框架可以换循环逻辑是通用的。理解了 ReAct你再去看各种 Agent 产品、编排框架会发现万变不离其宗。2. ReAct 的循环机制Thought、Action、Observation 是如何配合的2.1 四个关键角色的职责整个循环里其实只有四个关键产物搞懂它们ReAct 就懂了一半。Thought思考模型用自然语言说明我现在缺什么信息、为什么缺、打算怎么办。它不一定对用户可见但它是调试 Agent 最重要的线索。Action行动模型从工具列表里选中一个工具并给出参数。在主流接口里这表现为结构化的 tool_calls而不是可执行代码。Observation观察你的程序执行工具后把结果文本放回消息历史里作为模型的眼睛。Final Answer最终答复当模型觉得信息齐全、没有再调用工具的必要时它输出一段普通文本循环结束。打个比方Thought 是开车时看路牌并决定下一段怎么走Action 是转动方向盘Observation 是车窗外的景色变化Final Answer 就是到达目的地后给你的汇报。每一步的真实反馈都在修正下一步的决策这是 ReAct 最本质的地方。2.2 ReAct 和 CoT、Plan-and-Execute 有什么区别很多人分不清这几个概念我用对照表整理一下模式是否获取外部反馈决策方式典型问题CoT思维链否一次性生成完整推理链路推理再长也没有新信息链上一步错则步步错Plan-and-Execute否先计划后执行制定完整计划再逐步执行计划阶段对未知的中间状态无能为力ReAct是思考一小步、行动一小步根据观察修正需要控制循环成本防止无限调用在真实任务里绝大多数场景的中间状态都是未知的。比如一个查询航班 → 查询天气 → 给出出行建议的任务你只有在拿到第一步结果之后才知道第二步该查哪个城市。Plan-and-Execute 想提前把全部步骤定死往往计划赶不上变化。ReAct 的做法更务实走一步看一步观察会不断修正推理方向。这也是它被称为最实用的 Agent 范式的原因。2.3 循环结束的条件不是模型说了算ReAct 循环不会自己停下来。你必须显式定义终止条件否则模型可能一直在工具之间打转。我常用的终止条件有这么几类模型输出了没有 tool_calls 的普通文本说明它认为可以回答了达到最大轮数我通常设 8 到 12 轮涉及写操作的场景会更保守达到 token 或成本上限直接熔断检测到相同的工具 参数反复出现第 5 节会展开讲。终止条件的工程实现和工具调用本身同等重要。很多人第一次写 Agent只写了能调工具的快乐路径结果线上跑起来之后模型在一个错误的工具调用上反复横跳调用配额和预算都在无声流失。这个坑我踩过不止一次所以先把终止条件列在前面提醒你。3. 工具接口设计怎么让模型看得懂选得对输不错3.1 用结构化声明给模型画一个清晰的能力边界目前主流大模型服务基本都支持一种工具调用 / 函数调用式的接口你在请求里传一个工具列表每个工具包含函数名、描述和参数结构。模型在需要时并不是返回一串可执行代码而是返回一个结构化的意图——我要调用函数 X参数是 Y真正执行是在你的程序里完成的。这个设计有它精妙的地方模型始终碰不到真实系统安全边界在你手里。你完全可以在执行前做校验、做权限判断、做审计日志。工具声明我习惯用类似这样的一份 JSON 规范TOOLS_SPEC [ { type: function, function: { name: get_current_time, description: 获取当前的日期和具体时间适合回答今天是几号、周几、现在几点等问题。, parameters: { type: object, properties: {}, required: [] } } }, { type: function, function: { name: get_weather, description: 查询指定城市的实时天气和温度适合回答今天会不会下雨、适不适合出门、要不要带伞、穿什么衣服等问题。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如A市 } }, required: [city] } } } ]注意这里的type: function是通用说法不同服务商的字段名略有差别但大同小异。声明工具时描述信息直接决定模型的选择质量这一点比参数结构本身更重要。3.2 description 写得好不好直接决定模型选不选得对工具的 description 是模型判断该不该用这个工具的唯一依据。我见过太多工具声明只写一句查询天气结果模型在用户问今天要不要穿外套时根本想不到去调它——因为描述里没提穿衣出门这些相关语义。我的经验是把 description 写成两句式第一句讲清楚这个工具能提供什么信息第二句给出典型的触发场景。对比一下写法效果查询天气。太泛模型难以建立穿衣、带伞、出行与它的关联查询指定城市的实时天气和温度适合回答今天会不会下雨、适不适合出门、要不要带伞、穿什么衣服等日常出行问题。明确触发场景模型在相关提问下几乎必选它同样参数里的 description 也要写人话。参数描述越具体模型生成参数时越不容易编错。别小看这些描述它比你在系统提示词里反复强调请使用工具有效得多。一句话你把工具描述写得多细模型对工具的理解就有多准。3.3 参数校验和观察结果回填让循环的每一环都可靠模型生成的参数并不可靠它可能把城市名写成beijing, chaoyang district可能漏掉必填字段也可能发明一个不存在的文件路径。所以工具执行器必须做一层防御先校验参数格式与类型再执行真实逻辑校验失败时不要直接抛异常终止整个 Agent而是把一段可读的错误说明作为 Observation 塞回去让模型自己修正。观察结果的格式同样重要。工具返回值建议统一序列化为紧凑的 JSON并且只保留模型推理需要的关键字段。一个大而全的接口返回可能有几百个字段你全塞给模型既浪费 token又会干扰它的注意力。我在序列化工具结果时通常会做三件事字段精简、长度截断、错误统一标识。核心逻辑长这样def format_observation(data) - str: if isinstance(data, dict): compact {k: v for k, v in data.items() if v is not None} text json.dumps(compact, ensure_asciiFalse) return text[:500] # 超过部分截断 return str(data)[:500]这里的 500 字符不是绝对标准得看你的模型上下文窗口和任务复杂度来调。基本原则是观察结果要短到模型看得完又要完整到模型不用再猜。4. 手把手实现一个最小 ReAct Agent4.1 准备工作和整体结构这一节我给出一个真的能跑起来的最小实现。环境要求很简单Python 3.9 以上一个支持工具调用的模型服务接口以及 requests 库。我不依赖任何 Agent 框架因为只有从裸接口写一遍你才能真正理解循环里每一步在做什么。代码分四个部分模型调用封装、工具注册表、执行器、主循环。工具我先只放两个get_current_time 和 get_weather。为了让你能直接复现天气查询我用本地模拟数据不依赖第三方服务。4.2 模型调用封装import json import requests API_URL https://api.example.com/v1/chat/completions # 替换成你的服务商地址 YOUR_API_KEY sk-your-key-here # 替换成你的密钥 def chat_with_model(messages, toolsNone): body { model: your-model-name, messages: messages, tools: tools or [], temperature: 0.1, } resp requests.post( API_URL, headers{ Authorization: fBearer {YOUR_API_KEY}, Content-Type: application/json, }, jsonbody, timeout60, ) resp.raise_for_status() data resp.json() return data[choices][0][message]把 temperature 设低是我对 Agent 场景的第一条建议。工具调用是判断题不是创作题温度越低模型越倾向于稳定的选择。如果最终答复你想让文字更生动可以在最后一步单独用较高温度重新生成但中间的循环阶段请保持低温。4.3 工具注册与执行器def get_current_time(): from datetime import datetime now datetime.now() return { datetime: now.strftime(%Y-%m-%d %H:%M:%S), weekday: now.strftime(%A) } def get_weather(city: str): # 本地模拟数据真实场景替换为天气服务接口 fake_db { A市: {temp: 26, condition: 晴, humidity: 40, wind: 3级}, B市: {temp: 18, condition: 小雨, humidity: 85, wind: 2级}, } if city in fake_db: return {city: city, **fake_db[city]} return {error: f暂不支持查询 {city} 的天气请确认城市名称} def format_observation(data) - str: if isinstance(data, dict): compact {k: v for k, v in data.items() if v is not None} text json.dumps(compact, ensure_asciiFalse) return text[:500] return str(data)[:500] def execute_tool(name: str, arguments: dict) - str: tool_map { get_current_time: get_current_time, get_weather: lambda args: get_weather(args[city]), } if name not in tool_map: return json.dumps({error: f未知工具: {name}}, ensure_asciiFalse) try: result tool_map[name](arguments) except KeyError as e: result {error: f缺少必要参数: {str(e)}} except Exception as e: result {error: f工具执行失败: {str(e)}} return format_observation(result)执行器是所有安全策略的落脚点参数校验、权限检查、错误捕获都在这一层做。哪怕后面的循环写得再漂亮执行器不设防Agent 就等于裸奔。我自己的项目里凡是涉及写操作的函数这一层还会额外加是否需要人工确认的判断。4.4 主循环def run_agent(user_query, max_steps8): messages [ {role: system, content: 你是一个会调用工具来完成任务的助手。请按需调用工具获得结果后再回答用户。}, {role: user, content: user_query}, ] for step in range(max_steps): msg chat_with_model(messages, toolsTOOLS_SPEC) messages.append(msg) tool_calls msg.get(tool_calls) if not tool_calls: # 模型没有输出工具调用意图说明它认为可以给出最终答复了 return msg[content] for tc in tool_calls: fn_name tc[function][name] raw_args tc[function].get(arguments) or {} try: args json.loads(raw_args) except json.JSONDecodeError: args {_parse_error: raw_args} observation execute_tool(fn_name, args) messages.append({ role: tool, tool_call_id: tc[id], content: observation, }) print(f[step {step}] {fn_name}({raw_args}) - {observation}) return 已达到最大步数任务未完成已停止。这里最关键的一行是if not tool_calls: return msg[content]。模型没有输出工具调用意图就说明它认为信息已经够用循环应该在这里结束。把工具结果以 roletool 的消息放回历史是几乎所有主流 API 的通用约定务必保持 tool_call_id 一一对应。4.5 跑一个完整的例子if __name__ __main__: answer run_agent(A市今天适合晨跑吗请给出明确建议。) print(最终回答, answer)实测的循环过程大概长这样[step 0] get_weather({city: A市}) - {city: A市, temp: 26, condition: 晴, humidity: 40, wind: 3级} 最终回答 A市今天天气晴气温26度湿度40%风力3级比较适合晨跑……你可能会疑惑为什么模型第一步就直接查天气而不是先查时间确认今天是哪天这取决于模型对问题的理解和工具描述的吸引力不同模型行为会有差异。有的模型会先 get_current_time再用具体日期去查天气。无论哪条路径只要观察结果来自真实数据、最终答复源自观察结果这个 Agent 就是合格的。5. 实测中最容易翻车的三个坑循环失控、参数幻觉、错误被吞5.1 死循环模型在工具之间反复横跳我见过最典型的问题不是工具写错而是模型不收敛。比如一个查询任务模型第一次调 get_weather 得到温度 26 度第二次又用完全相同的参数再调一次第三次调 get_current_time第四次又回来调 get_weather……输出看起来在推进实际在原地打转。常见诱因有三个观察结果格式混乱让模型无法确认是否已拿到数据工具描述互相重叠导致模型在多个工具之间摇摆提示词里没有强调拿到关键数据后直接回答。我自己的防御方案有三层最大步数熔断上面代码里的 max_steps8 是第一层保险重复调用检测记录工具名与参数的组合同一组合连续出现两三次直接中断成本看板每次循环都累加 token 消耗超过预算就强制收尾并通知用户。重复调用检测的示意代码seen_calls [] # 在每次执行工具后执行以下检查 signature f{fn_name}|{json.dumps(args, sort_keysTrue)} seen_calls.append(signature) if seen_calls.count(signature) 3: return 检测到陷入重复调用基于已有信息给出回答。5.2 参数幻觉模型会一本正经地编参数模型生成工具参数时一本正经地编是常态。它可能把用户没提过的城市填进去可能给 get_weather 填一个昨天的日期还可能凭空写一个文件路径。原因在于模型对参数值的理解来自训练语料而不是对话里的实时事实。应对参数幻觉除了在 JSON Schema 里做约束比如城市用枚举、日期用正则校验更重要的是执行器校验和错误回填。我曾遇到一个模型反复把城市参数填成另一个城市名而不是对话里提到的 A 市执行器返回未知城市之后模型在下一轮就修正过来了——因为它看到了现实世界的反馈。另一个有效做法是在系统提示词里加一句工具参数必须严格来自用户对话或之前工具返回的数据禁止编造任何值。这句话不贵但能明显降低幻觉率。如果你发现某个工具总是被填错参数请优先检查这个工具的 description 是不是没写清楚参数取值来源。5.3 观察返回出错时模型可能假装没看见真正让我意外的是这一条有些模型在工具返回错误时会在最终答复里报喜不报忧。比如天气接口挂了Observation 是一段 error 信息模型却回答查询成功A 市今天晴。原因在于通用模型天生倾向于生成让用户满意的答复而不擅长承认我失败了。解决办法是双管齐下。一方面把错误信息的格式做得非常醒目比如{status: error, reason: 服务不可用}另一方面在系统提示词里给模型立规矩如果工具返回错误必须在最终答复中如实说明失败原因不得编造成功结果。在评估 Agent 时这也是我重点检查的一条——宁可让模型说查不到也不能让它编一个查到。6. 从 Demo 走向生产工程化落地的几个关键思考6.1 工具权限不是所有函数都该被模型随便调Demo 里只有两个只读工具到了生产环境你的工具列表里可能混着发送短信、创建订单、删除文件这类高敏感操作。如果把这些无差别地暴露给模型一次参数幻觉就可能造成真实损失。我的原则是把工具按风险分级只读工具完全开放写操作需要二次确认破坏性操作默认不允许只有特定场景显式授权才放开。所谓二次确认就是在执行写操作前引入人机环节——模型生成调用意图后先把即将执行的动作摘要展示给用户用户点确认才真正执行。这一步会牺牲一点自动化程度但换来的是可控性。对任何面向真实用户的产品这都是值得的投入。6.2 上下文膨胀与压缩ReAct 循环越长成本越高每次工具调用都会把一条 tool 消息追加到对话历史里循环到第 8 步时历史里可能塞了几千字的中间结果。这不仅拖慢响应、推高成本还会让模型在长上下文里迷失重点。我的处理方案按顺序做三件事工具结果截断第 3 节里提到的长度上限是控制膨胀的第一道闸历史摘要每 N 轮后把前面的 Thought/Observation 压缩成一段摘要替换掉原始轮次上下文淘汰保留最近若干轮的完整内容更早的内容只保留结论避免噪声累积。这里有一个我踩过的坑压缩历史时如果不小心破坏了工具调用与 tool 消息的 id 对应关系接口会直接报错。所以压缩操作最好在协议栈层面处理用一个独立的精简消息列表传给模型原始完整记录只进日志。6.3 可观测性与评估没有日志的 Agent 等于盲人开车Agent 是多步决策系统它的行为很难靠一次对话复现。没有完整日志你根本无法回答它为什么多调了一次工具这个观察结果为什么让回答跑偏了。所以从第一天写 Agent 起我就坚持把每一步的结构化日志落盘轮数、工具名、参数、观察结果、token 消耗、耗时逐条记录再配一个回放工具把某次请求的完整循环按时间顺序重现出来排查问题会快很多。评估方面我给 Agent 建了一个固定的黄金用例集每个用例包含用户问题、允许调用的工具、期望的工具调用序列、期望的最终结论。每次改动工具描述或提示词都拿这批用例回归一遍。我主要看两个指标工具选择准确率该调的调了、不该调的不调和最终回答的事实准确率是否严格基于观察结果。这些指标一开始不会高但有了它们你才谈得上迭代。最后聊一点我的个人体会。把大模型接上外部工具之后Agent 才真正从会说话变成会办事但这中间的差距不是写一个循环就完事而是工具设计、错误处理、成本控制、权限管理这一整套工程问题。我建议新手先不要碰任何框架用裸接口把一个单工具的 ReAct Agent 跑通再逐步加工具、加防御、加评估。每加一个工具之前都问自己一句这个工具的 description 能让模型一眼看懂何时用吗它的失败模式是什么返回结果会不会误导下一步推理这三个问题想明白了你的 Agent 就比市面上大多数 Demo 稳得多。