
最近被问得最多的问题差不多可以浓缩成一句光有一个大模型LLM的 API凭什么能做出一个会查资料、会算账、能自己安排执行步骤的 AI Agent 如果只是把问题直接丢给模型它当然做不到——模型没有手、没有脚也够不到那些没被训练过的实时数据。让这一切成立的核心是我今天要讲的 ReAct 框架让模型反复执行 Thought → Action → Observation 这个循环再配合 Function Call 接口把Action真正落地成一次可执行的函数调用。这篇文章不聊空泛的概念我会带着你从零写一个基于 LLM Function Call 的 Agent从工具注册到循环控制再到实测里最容易踩的坑按照真实项目推进的顺序一步步来。这套内容适合谁如果你已经会调大模型 API但还没想清楚怎么让模型主动决定调用什么工具或者你正准备在团队里搭一个能多步执行任务的 Agent这篇文章正好对口。我会尽量让每一段都有代码、有理由、有避坑说明哪怕你之前完全没写过 Agent照着抄也能跑通一个最小可用版本。1. ReAct 与 Function Call 的互补逻辑模型如何从回答者变成执行者1.1 大模型再聪明也够不到它没拿过的数据先说清楚一个很多人忽略的事实LLM 本质是一个根据上下文续写文本的系统它擅长的是知识记忆、语义理解、文字生成但它有三个天然短板知识截止它只记得训练时见过的内容之后发生的事一概不知。数据隔离你的私有数据、后台数据库、实时行情、设备状态它都拿不到。动作缺失它只能输出文字不能真正调用一个接口、写入一条记录、触发一个流程。所以过去很多 Demo 都停在问答这一层用户问一句模型答一句答不上来就编。Agent 的破局思路其实是给模型装上外部器官工具。模型负责思考现在该做什么、该用哪个工具、参数怎么填系统负责真的把这个工具跑起来再把结果喂回给模型让它继续下一步。这套思考 行动 观察反馈的循环2022 年的一篇论文把它总结成了 ReAct——Reasoning and Acting。但论文提出的时候模型的输出还是纯文本工具调用要靠我们自己写正则去解析真正让 ReAct 变得工程可用的是后来大模型 API 普遍支持的 Function Call 协议。把这两个东西放一起看你才会明白现在各种 AI Agent 的底层到底在干什么。1.2 ReAct 三件套Thought、Action、ObservationReAct 的循环其实特别像人解决问题的过程先想一下需要什么信息然后去查、去算、去试看到结果后判断够不够不够就再来一轮。落实到 Agent 上就是三个固定动作Thought模型用自然语言描述我现在遇到了什么问题下一步需要获取什么信息。Action选择一个工具给出调用参数由系统执行。Observation系统把工具执行的结果返回给模型作为下一轮推测的依据。我举个非常生活化的例子。假设我让 Agent 判断明天出门要不要带伞并且给它挂了一个天气查询工具。整个循环会长这样Thought用户想知道明天是否需要带伞这需要明天的降雨信息。Action调用get_weather(city用户所在城市, date明天)。Observation明天中雨降水量 8mm。Thought明天下雨出门应该带伞。Final Answer建议带伞。注意这里和平时我们说的思维链Chain-of-Thought不一样。思维链是让模型闭着眼睛推理答案完全来自模型内部知识ReAct 是每走一步都睁开眼睛看外部反馈拿到 Observation 之后再做下一步判断。也正是这种循环 反馈让 Agent 能处理那些模型靠记忆根本无法回答的问题比如查实时数据、算复杂算式、操作外部系统。1.3 为什么 Function Call 比用嘴巴喊工具名更可靠ReAct 论文最初的实现方式是让模型输出这样的文本Thought: 我需要知道今天的天气。 Action: Search[明天 xx 市天气] Observation: 明天下雨然后我们在代码里用正则匹配Action:这一行提取工具名和参数再去转发给对应的函数。这套方案今天仍然有意义尤其是当你用的模型不支持 Function Call 时它几乎是唯一的选择。但它的缺点实在太明显格式不稳定模型偶尔会多打一个空格、少个括号写成Search [xx]正则直接匹配不到。工具名幻觉模型可能自创一个工具名比如SearchWeather而我们注册的是get_weather。解析成本高用户问题、Thought、Action 全混在一起解析逻辑会越写越复杂。Token 浪费Action 那行算输出 token解析失误还会多消耗几轮重试。主流大模型 API 后来提供的 Function Call 协议就是用结构化字段代替文本约定。模型该调用工具时API 直接返回一个tool_calls数组里面明确给出工具名、参数 JSON 字符串不再需要我猜它要调用什么。对比维度文本式 ReActFunction Call 式 Agent模型输出形式自然语言混动作结构化函数调用字段工具参数解析正则 规则脆弱协议直接给出 JSON错误典型工具名拼写错误、括号不匹配参数值幻觉、工具目标偏差对模型要求任意文本模型都能跑需要模型支持 function call 协议工程维护成本解析规则越来越多干净依赖 SDK 协议一致我的建议是如果模型支持 Function Call优先走 Function Call如果只有纯文本模型可用再退回文本式 ReAct 作为兜底方案。后者虽然糙但关键时刻真的能救命——我在后面第 5 章会给出一个简单解析的兜底写法。2. 先搭工具层注册中心与 JSON Schema 的契约设计2.1 最小 Agent 的四个组件在写循环之前先把整个 Agent 的边界画清楚。一个最小可用的 ReAct Agent通常只需要四个组件模型客户端负责把 messages 和 tools 发给大模型拿回回复。工具注册中心维护所有可用工具的函数实现和 JSON Schema 描述供模型感知、供系统调用。循环控制器执行调模型 → 判断是否有工具调用 → 执行工具 → 回填 Observation → 再来一轮。上下文管理维护 messages 列表包含系统提示、用户问题、中间产生的 assistant 消息和 tool 消息。很多新手一上来就去套重量级框架结果被框架的抽象层层裹住出了问题根本无从下手。我建议第一版自己写几十行代码就能跑通你对每一步都有完全的控制权后面再考虑要不要换框架。组件划分很重要的一点是模型只负责决定系统只负责执行。模型永远不应该直接执行代码系统也永远不应该替模型做判断。这个边界越清晰Agent 越不容易失控。2.2 工具描述就是给模型的说明书Function Call 能否成功一半取决于工具描述写得够不够好。模型的工具选择逻辑本质上是在读你的 JSON Schema 说明然后决定该用哪个、参数填什么。所以工具描述里每一项都别糊弄。以get_today这个工具为例我会这样定义它的 schema{ type: function, function: { name: get_today, description: 返回今天的日期输出格式为 YYYY-MM-DD例如 2026-03-01。如果用户问到今天、现在等时间概念请调用这个工具。, parameters: { type: object, properties: {}, required: [] } } }这里有几个新手很容易踩的细节description 要写清楚什么时候用。最好带触发条件比如当用户提到今天时调用模型命中率会高很多。parameters 的每个字段都要写类型最好给出格式示例。模型填参数时会把 description 当成提示词来读示例越具体参数越容易填对。required 别漏。漏了required模型可能不填关键参数你执行工具时就只能收到一个缺胳膊少腿的参数对象。一句话总结给模型写工具说明就像给一个完全不懂业务的实习生写交接文档越具体、越有例子他越不会做错。2.3 用注册器把函数变成协议有了 schema还要有配套的函数实现。为了不让函数列表和函数实现两处维护、两处漂移我习惯用一个注册器把两者绑定在一起用装饰器的方式声明工具import datetime import json import re class ToolRegistry: def __init__(self): self._funcs {} self._schemas [] def register(self, name, description, parameters): def decorator(func): self._funcs[name] func self._schemas.append({ type: function, function: { name: name, description: description, parameters: parameters, }, }) return func return decorator def invoke(self, name, arguments: dict): if name not in self._funcs: raise ValueError(f未注册的工具: {name}) return self._funcs[name](**arguments) property def schemas(self): return self._schemas注册中心的核心价值是你在定义一个业务函数的同时就把给模型看的说明书和给系统执行的真实函数放在了一起新增一个工具只要加一个装饰器不用再去改循环逻辑。后面组 Agent 的时候tools.schemas直接喂给大模型tools.invoke直接执行模型提出的调用建议两边统一。3. 核心循环落地Thought、Action、Observation 的逐行实现3.1 识别模型返回中的 tool_calls工具层就绪后最关键的就是循环控制器。每轮循环我只需要做三件事发请求、看返回、决定下一步。以大模型 API 常见的返回结构为例一个消息对象通常包含content和tool_calls两个字段。tool_calls为空说明模型决定直接回答问题tool_calls非空说明模型想要调用工具。核心判断逻辑长这样response llm_chat(messages, toolstools.schemas) msg response.message if not msg.tool_calls: # 模型没有调用工具的意图这就是最终回答 return msg.content # 否则模型想要调用一个或多个工具 for tool_call in msg.tool_calls: print(工具名:, tool_call.function.name) print(参数:, tool_call.function.arguments)这里不要犯一个低级错误不要把模型的content当最终答复直接返回。在 Function Call 流程里模型常常会在content里写我需要先获取今天的日期同时在tool_calls里附上真正的工具调用请求。只有tool_calls为空时content才意味着我已经有答案了。3.2 执行工具并回填 Observation判断出模型要调用工具后系统这边要执行真实函数并把结果写回对话里这一步就是 ReAct 里的 Observation。回填时有一个硬性要求每条工具结果都要和对应的tool_call_id绑定并且严格按照assistant 请求调用 → tool 返回结果 → 再调模型的顺序排列。顺序错了、ID 对不上模型就会失去上下文关联表现会突然变得非常弱智。一个完整的回填逻辑如下def run_agent(user_question, max_steps8): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_question}, ] for step in range(max_steps): response llm_chat(messages, toolstools.schemas) msg response.message # 保留 assistant 消息含 tool_calls messages.append({ role: assistant, content: msg.content or , tool_calls: msg.tool_calls, }) if not msg.tool_calls: return msg.content # 逐个执行工具调用 for tool_call in msg.tool_calls: name tool_call.function.name try: args json.loads(tool_call.function.arguments or {}) result tools.invoke(name, args) observation json.dumps(result, ensure_asciiFalse) except Exception as e: observation f工具调用异常: {type(e).__name__}: {e} messages.append({ role: tool, tool_call_id: tool_call.id, content: observation, }) return 超出最大工具调用轮数未能得到最终答案。这段代码就是整个 Agent 的心脏。它把模型、工具、上下文串成了一个闭环模型提出工具调用 → 我们执行 → Observation 写回 → 模型看到结果继续推理。每一步都在扩大上下文直到模型认为自己拥有足够信息不再发起工具调用。3.3 什么时候该停终止条件与轮数上限Agent 的循环不能无限跑下去否则一个 bug 就能烧掉大量的 token。终止条件至少要设计两个正常终止模型没有返回tool_calls说明它认为可以回答用户了。强制终止达到max_steps强行停止返回兜底文案。max_steps建议根据任务复杂度来定普通工具调用任务 5~8 轮足够。不要天真地以为模型会自动收敛实际运行时你一定会遇到它反复调用同一个工具、或者一直在再查一下的尴尬场景。轮数上限是最后一道保险更精细的防死循环手段我放在第 5 章讲。4. 完整可跑示例让 Agent 自己安排查日期→算间隔→算存款4.1 准备三个实用工具理论讲完来一个能直接跑的完整示例。我准备三个工具查今天日期、算两个日期之间的天数、做四则运算。这三个工具组合起来正好能考一考 Agent 的多步规划能力。tools.register( get_today, 返回今天的日期输出格式为 YYYY-MM-DD例如 2026-03-01。当用户提到今天、现在、当前日期时调用。, {type: object, properties: {}, required: []}, ) def get_today(): return datetime.date.today().isoformat() tools.register( days_between, 计算两个日期之间相差的天数输入格式为 YYYY-MM-DD返回整数天数。, { type: object, properties: { date1: {type: string, description: 起始日期格式 YYYY-MM-DD}, date2: {type: string, description: 结束日期格式 YYYY-MM-DD}, }, required: [date1, date2], }, ) def days_between(date1, date2): d1 datetime.date.fromisoformat(date1) d2 datetime.date.fromisoformat(date2) return abs((d2 - d1).days) tools.register( calculator, 执行一个四则运算表达式并返回数值只支持数字、加减乘除、括号和空格。例如 30 * 5 或 (100 20) / 3。, { type: object, properties: { expression: {type: string, description: 数学表达式字符串}, }, required: [expression], }, ) def calculator(expression): # 先过滤掉不合法字符降低直接求值的风险 safe re.sub(r[^\d\-*/(). ], , expression) if safe ! expression: raise ValueError(表达式包含非法字符) # 演示用 eval生产环境请改用 ast 解析求值 return eval(safe)这里必须多说一句eval在真实项目里是危险的因为它直接执行任意代码。我在这里只是为了演示 ReAct 流程的简洁性你自己落地产出时请务必用ast模块解析表达式树或者只允许白名单运算符。工具的安全边界怎么强调都不过分。4.2 组装完整 Agent 代码有了注册中心和工具类之后主角就是前面那段run_agent。把它整合起来系统提示词也值得认真写我通常会这样设定SYSTEM_PROMPT 你是一个通过工具完成任务的助手。 - 当需要外部信息、日期计算或精确运算时先调用对应工具而不是凭记忆回答。 - 每次看到工具返回的 observation 后判断信息是否足够不够继续调用工具足够则用自然语言回答。 - 不要编造工具返回结果所有结论必须来自 observation 或用户提供的信息。 - 如果工具连续失败停止尝试并如实向用户说明遇到的问题。整体拼起来就是一个可运行的脚本定义llm_chat接你当前用的 SDK、注册工具、写系统提示词然后调用run_agent(今天是几号到 2027-01-01 还有多少天如果这些天每天存 5 元一共能存多少)。4.3 跑一个三连调用的任务看循环怎样收敛我实际跑这个问题的过程基本长这样第 1 轮模型在 content 里写我需要先获取今天的日期同时发起get_today({})。系统执行后把 Observation2026-03-01写回。第 2 轮模型看到日期是 2026-03-01判断还需要算日期差于是调用days_between({date1: 2026-03-01, date2: 2027-01-01})Observation 返回306。第 3 轮模型拿到 306 天意识到还要算总金额调用calculator({expression: 306 * 5})Observation 返回1530。第 4 轮模型不再发起工具调用直接给出最终答案今天是 2026-03-01到 2027-01-01 还有 306 天每天存 5 元一共能存 1530 元。整个过程没有任何人告诉模型要先查日期再算天数它就是靠系统提示词和工具描述自己规划出了这三步。这就是 ReAct 框架最让人兴奋的地方你在给模型规则而不是给模型步骤模型自己完成步骤编排。当然实际输出不会每次都一模一样。有时模型会在一步里同时发起两个工具调用并行调用有时它会调整中间步骤的顺序。只要最终推理逻辑正确都属于正常波动。5. 实测中躲不开的四个坑上下文膨胀、死循环、异常与模型乱答5.1 上下文膨胀一次搜索就把窗口塞满第一个坑是上下文爆炸。ReAct 每执行一轮工具调用就要在 messages 里追加一条 assistant 消息和一条 tool 消息。如果工具的 Observation 特别大——比如搜索接口返回整整一屏内容、数据库查询返回几百行记录——那么跑上三四轮上下文窗口就快满了。后果是费用越来越高、响应越来越慢、模型在超长上下文里反而更容易答非所问。我常用的三个手段是工具输出截断写一个compact_observation把超长内容截到几百字并加省略标识。def compact_observation(text, max_len500): if len(text) max_len: return text return text[:max_len] f...[已截断共 {len(text)} 字符]限制轮数max_steps本来就起到了限制上下文无限增长的作用。历史压缩如果任务确实很长可以把早期轮次的 Thought 和 Observation 压缩成一行摘要再放回 messages把原始长内容删掉。尤其要注意第 3 点。我在项目里就见过一个 Agent因为搜索工具返回的网页正文太长导致第三轮就开始变笨后面所有问题都答得像在复读。发现问题后我把每条 Observation 都截断到 300 字同一套逻辑立刻就正常了。5.2 死循环与重复动作加最后一道闸第二个坑是模型钻牛角尖。它可能连续五轮都在调用同一个工具参数几乎没变每次拿到 Observation 后又觉得还不够。这不是模型坏了而是它在信息不足时缺少一个止损信号。max_steps是兜底但更好用的是重复动作检测。我通常在循环里维护一个最近的 tool call 列表发现连续三轮调用同一个工具时主动往 messages 里注入一条干预消息if step 3 and len(set(recent_calls[-3:])) 1: messages.append({ role: user, content: 你已经连续三次调用同一个工具请不要再重复调用直接基于现有信息回答用户。, })这一步效果非常明显。模型看到干预消息后通常会停止重复搜索转而尝试总结或给出一个诚实的能力不足答复。注意干预消息用user角色比system角色更接近用户不耐烦的语义模型往往会更认真地对待。5.3 工具异常处理把错误信息变成给模型的方向标第三个坑是工具执行异常。模型是可能填错参数的比如calculator收到表达式: 306*5这种带中文冒号的字符串或者天数计算收到一个格式错误的日期。如果你的工具直接抛异常整个 Agent 就会崩溃。正确做法是把异常捕获后放进 Observation。我在前面代码里已经写了observation f工具调用异常: {type(e).__name__}: {e}不要小看这行字符串它是模型下一轮自我纠错的关键线索。比如 Observation 写着ValueError: 表达式包含非法字符模型会意识到哦我给的参数格式不对然后重新生成一个格式规范参数的调用。这比直接让它面对一个空白失败要聪明得多。还有一点要养成习惯能返回错误对象就别直接抛异常。有些函数的设计是正常结果返回数值参数错误返回带 error 字段的字典这两种方式都能让模型读到错误但是后者不会污染调用栈整体更可控。5.4 模型选型与提示词细节第四个坑藏在最不起眼的地方选型与提示词。首先能选支持 Function Call 的模型就选支持 Function Call 的别用纯文本模型硬扛。模型的工具调用能力质量差异极大有的模型能准确理解 JSON Schema 并给出规范参数有的模型则频繁填出莫名其妙的参数类型。在项目初期我会用一组固定测试问题来横向对比模型的工具调用成功率而不是只看它在开放对话里显得多聪明。其次调低采样参数。工具调用需要稳定我把 temperature 设在 0 到 0.1top_p 也一并调低换来的是更少的随机性。让模型做创意写作时可以放飞让模型控制工具调用时必须保守。最后提示词里一定要写不要编造工具返回结果。这个要求听着傻实际非常管用。没有这句约束模型有时会跳过工具直接编一个答案尤其当问题稍微复杂、工具调用链较长的时候。把这条写进系统提示词能显著减少假 Observation的现象。6. 从 Demo 迈向可用记忆、并行调用与运行日志的扩展思路6.1 多轮会话与短期记忆Demo 只处理单轮问题但真实产品里用户会连续追问。多轮会话的核心是把前面几轮对话的助手最终回答保留在 messages 里而不是每次重新从空上下文开始。要注意的是不要在用户的新问题里夹带上一轮的工具调用记录。上一轮的工具 Observation 只对那一轮的问题有效下一轮用户换了个话题旧 Observation 反而会干扰模型。通常我会把上一轮的最终回答保留成普通 assistant 消息把中间的过程消息全部清理掉或者用一个短期记忆模块对历史做摘要。6.2 并行工具调用现在很多主流 API 允许模型在一次返回里发起多个 tool_calls。比如模型同时发现我需要知道今天的日期和我需要查某个价格它会一次性返回两个调用请求。这种请求我建议并行执行能显著降低整体耗时。用 Python 的ThreadPoolExecutor就可以注意两点一是回填 Observation 时顺序要和 tool_calls 顺序保持一致避免模型混淆二是如果多个工具之间有依赖关系比如后一个工具需要前一个工具的输出那就别并行老老实实按顺序来。from concurrent.futures import ThreadPoolExecutor def execute_tool_calls(tool_calls): with ThreadPoolExecutor(max_workers4) as pool: futures {tc.id: pool.submit(execute_one, tc) for tc in tool_calls} return {tc_id: fut.result() for tc_id, fut in futures.items()}并行不是越快越好像写入数据库发送通知这类有副作用的工具并行执行可能带来重复操作风险需要单独做好幂等。6.3 运行日志与可观测性最后一点也是我自己把 Agent 接进业务环境时最看重的一点可观测性。ReAct Agent 是一个多步决策系统中间哪一步错了、哪一步 token 烧多了、哪一步模型产生了幻觉光看最终答案根本看不出来。所以我在循环里一定会记录每一步的 Thought、Action、Observation、耗时和 token 消耗丢到一个结构化日志里。run_log [] for step in range(max_steps): ... run_log.append({ step: step, thought: msg.content, tool_calls: msg.tool_calls, observation: observation, latency_ms: latency_ms, tokens: usage_tokens, })调试时我会直接看这个日志一眼就知道模型在第几步开始跑偏。就连我后面调优系统提示词也是靠翻这些日志找规律。个人经验是先把日志做好再谈优化效果这一步绝对不能省。Agent 一旦变复杂盲调就像在黑屋子里找开关日志就是唯一的手电筒。