主题
ReAct 的本质就是一个 while 循环,每一轮做三件事:让 LLM 思考(Reasoning)、让 LLM 决定调用工具(Acting)、把工具结果喂回去再思考(Observation)。我这个项目就是把这个循环自己实现了一遍,核心类是一个 Agent,入口是它的 run 方法。
ReAct 循环流程
先讲进入循环前的预处理:裁掉上一轮历史里的图片 payload、把用户输入写进短期记忆、检索相关长期记忆重新组装 system prompt、如果有 Skill 上下文就前置到用户输入前面,最后把这条 user 消息追加进 conversationHistory。
然后进入主循环,每一轮是这样的:
- 调 LLM 之前先做几道检查——用户有没有取消、上下文窗口是不是接近上限(接近就把早期对话压缩成摘要)、以及预算兜底检查;
- 把当前对话历史 + 工具定义的 schema 一起发给 LLM;
- LLM 返回一个
ChatResponse,我判断它有没有带工具调用——- 没有:说明模型觉得可以直接回答了,把答案追加进历史、写记忆、记录 token,退出循环;
- 有:把这条带 tool_calls 的 assistant 消息追加进历史,然后并行执行这些工具,把每个结果作为一条 tool 消息回灌历史,回到第 1 步继续下一轮。
所以退出条件只有一条主线:LLM 自己决定不再调工具。这是 ReAct 的精髓——「什么时候停」交给模型判断,而不是我硬编码一个步数。
【主动抛深度点 1:并行执行和依赖】 这里有个细节可以展开:同一轮返回的多个工具调用我会用固定线程池并行执行,最多 4 个并发,而且结果要按原始顺序返回——因为回灌的时候 tool 消息的 id 必须和 assistant 消息里的 tool_calls 一一对应,顺序乱了协议就坏了。那工具之间有依赖怎么办?我的做法是不在单轮里处理依赖,依赖靠 ReAct 的多轮天然解决:模型拿到前一个工具的结果后,下一轮再调下一个。这是 ReAct 和后面 Plan-and-Execute 的根本区别——后者是在规划阶段就把依赖建模成 DAG 再拓扑执行。
【主动抛深度点 2:防死循环】 还有一个容易踩坑的点是死循环防护。我加了三个兜底:连续 N 次工具调用的「工具名+参数」完全相同就判定死循环、硬轮数上限、以及 token 预算。有意思的是 token 预算我默认设成无限,只有显式配置才生效——因为配合长上下文模型和套餐用户,我选择把「什么时候停」完全交给模型,死循环靠前两道来兜。
有依赖的工具你怎么保证不并行错乱?
诚实讲——单轮内我不处理依赖,同一轮的多个调用默认独立并行,这是 OpenAI function calling 的约定。真依赖靠多轮:模型拿到结果后下一轮再调。如果模型违规把依赖工具放同一轮,结果是 B 拿不到 A 的中间值、这轮可能浪费,但结果保序回灌后,模型下一轮看到两个结果会自己补一次,用「多浪费一轮」换最终正确性,这是 ReAct 的容错兜底。
上下文窗口满了怎么办?
有两道压缩,别混淆。一道压的是短期记忆,另一道才是真正决定下一轮 input token 的——对话历史压缩。触发阈值我按 window - 摘要预留 - 安全缓冲 算,200k 窗口大约 167k 触发。算法是:找出所有 user 消息的下标,保留最近 3 轮,把更早的内容调一次 LLM 摘要成 1-3 段,然后原地重建成「system + 摘要 user 消息 + 一条 assistant 兜底 + 最近 3 轮」。关键约束是分割点必须落在 user 边界,避免切断 tool_call/tool_result 的成对协议。摘要失败就放弃压缩、保留原历史。
并发执行具体用什么实现的?超时怎么办?
Executors.newFixedThreadPool,线程设成 daemon,invokeAll 带 90 秒批次超时。单个工具超时会被取消,返回「工具执行超时」的结果字符串占位,不影响其他已完成的工具。结果靠 invokeAll 的保序契约按提交顺序收集。
工具调用前有没有安全控制?
有,两层拦截。第一层 HITL——危险工具(写文件、执行命令)在执行前要人工审批,支持「全部允许/拒绝/跳过/改参数后批准」;第二层策略——PathGuard 强制路径限定在项目根内、CommandGuard 做命令黑名单。拒绝不抛异常打断循环,而是把「策略拒绝」作为字符串结果返回给 LLM,让模型能理解并换方案。
这个 ReAct 后来你怎么演进的?
ReAct 是最底层的执行原语,往上我做了两条编排路径:/plan 走 Plan-and-Execute(先规划出 DAG 再逐步执行),/team 走 Multi-Agent(一个编排器调度多个子 Agent)。它们都复用同一套 ToolRegistry、记忆和快照,区别只在「要不要显式规划」。
工具调用流程是怎样的?
CodeAgent CLI 的工具调用运行在 ReAct 循环中,整体可以分为四个阶段:工具决策、安全检查、工具执行和结果回灌。
第一阶段是工具决策。系统调用模型时,除了对话消息,还会通过 API 的 tools 参数传入所有可用工具的名称、功能描述和参数 Schema。模型结合当前任务和已有上下文,决定是否调用工具,并返回结构化的 tool_call,其中包含调用 ID、工具名称和 JSON 参数。
第二阶段是安全检查。工具请求不会直接执行,而是先经过确定性的安全策略,例如检查文件路径是否位于项目目录、命令是否包含被禁止的危险操作,以及浏览器操作是否涉及敏感行为。安全检查不依赖模型,即使用户批准了,也不能绕过底层策略。
第三阶段是 HITL 人工审批。读取文件、搜索代码等只读工具通常可以直接执行;写文件、执行命令、创建项目、回滚文件以及 MCP 外部工具默认需要确认。用户可以批准、拒绝、跳过,也可以修改参数后再执行。
第四阶段是执行和结果回灌。单个工具同步执行;如果模型在同一轮返回多个工具调用,系统最多使用四个线程并行执行。结果仍然按照原始 tool_call 的顺序返回,分别以带有对应 tool_call_id 的 Tool 消息写入对话历史,避免模型把结果对应错。
模型拿到工具结果后会进入下一轮推理:如果信息不足,就继续调用工具;如果任务已经完成,就返回最终答案。因此整体是一个“模型决策—安全控制—工具执行—结果回灌—再次决策”的闭环。
能不能用 Skill 替代 Tool?
不能。Tool 和 Skill 解决的是两个不同层面的问题。
Tool 提供真实的外部执行能力,例如读取文件、搜索代码、执行命令或者调用 MCP 服务;Skill 提供的是决策知识,告诉模型面对特定任务时应该采用什么流程、遵守什么规范,以及如何组合现有工具。例如代码审查 Skill 可以规定先查看变更范围,再检查安全问题,最后运行测试;但真正读取差异、搜索代码和运行测试,仍然需要 Tool 完成。Skill 本身不会操作文件或执行命令。
我把 Tool 理解为 Agent 的手,Skill 是可按需加载的操作手册。Skill 能提高工具选择和执行流程的准确性,但不能代替工具访问真实环境;Tool 能执行操作,但没有 Skill 时,模型可能不知道怎样更专业地组合这些操作。
八股延伸|LLM 和 Agent 之间的联系和区别
LLM 和 Agent 之间的联系和区别
联系是:Agent 的下一步通常由 LLM 生成。读完当前上下文之后,模型给出要说的话,或者给出要调的工具和参数。没有 LLM,Agent 就只剩一套脚本。
区别是职责。LLM 是无状态的文本生成:进一段上下文,出一段文本,这次调用结束它什么都不记得,也碰不到文件系统、终端、网络。Agent 是包在 LLM 外面的执行系统,把模型缺的三样补上:工具,让文本变成真实动作;循环,一次生成不够就观察结果再问模型;记忆,让多轮、甚至跨会话还知道之前发生过什么。
一句话:LLM 负责想下一步,Agent 负责让这一步发生,并决定要不要再想。
八股延伸|ReAct Agent 由哪几部分组成
ReAct Agent 由哪几部分组成
ReAct 是推理加行动的循环:模型先想这一步该干什么,再提出行动,框架执行后把观察写回上下文,模型再想。直到它给出最终回答,或轮数用尽。
落到实现上就四块,外加一块管得住循环的控制。
模型客户端负责和 LLM 通信,收回文本、思考过程和工具调用。工具侧分成「有什么能用」和「真去跑」:注册表给模型看可用工具和参数说明,执行器拿调用去读文件、跑命令、查网页。执行完必须把结果当观察塞回对话,下一轮模型才能接着想。循环控制决定继续还是停,防止工具来回打转。上下文管理看消息是否接近窗口,该压缩就压缩,不然后面轮次进不去。
一句话:LLM 负责想,工具负责干,观察负责让下一轮的想看见干的结果。
那用户交给它一个问题,整个流程是怎么走的?
“先做两件准备工作,把匹配到的 Skill 候选清单注入上下文,再检查一遍消息总量要不要压缩。”
“然后进入循环。把消息历史和工具清单一起发给模型,模型流式返回,要么直接给文本回答,要么发起工具调用。”
“是后者的话,执行器把工具跑完,结果以 tool 消息追加回历史,再次调模型。模型看到工具结果继续决策,要么接着调,要么给出最终回答。”
“退出条件有两个。模型不再请求工具,正常结束;或者到达硬上限(默认 50 轮,可配置)强制收尾,防止死循环烧 token。”
“执行上还有两个细节。一个是多工具调用并行执行,线程池上限 4,并行时每个工具的输出先写独立缓冲区、全部完成后按原顺序刷出,防止终端交错;审批(HITL)环节会串行化,逐个问用户;另一个是流式场景下工具调用的参数是分片到达的,要按序号把参数片段拼接完整,再解析成 JSON 交给执行器,拼早了就是半截 JSON,直接解析失败。”
八股延伸|如果从零设计一个 Agent 框架,会分哪些模块?
如果从零设计一个 Agent 框架,会分哪些模块?
“核心六层:”
- 入口层:命令行和交互式会话,管参数解析和结果渲染
- Agent 层:三种执行模式,ReAct 循环、计划执行、多 Agent 协作
- 模型层:统一的 OpenAI 兼容客户端,管流式解析、token 用量和计费
- 工具层:注册表加执行器,再加十七个内置工具
- 记忆与上下文层:短期消息历史、长期记忆库、上下文压缩
- 安全层:命令黑名单、路径守卫、高危操作人工确认、审计日志
“安全层容易被漏掉,单独说一句。Agent 是真的会执行命令的,黑名单里躺着的都是狠角色,sudo、rm -rf 这类命令直接拦截。”
“写文件和执行命令要么标了危险等级、要么强制人工确认,所有执行记录落审计日志。能力越大的系统,越要先想清楚怎么拦住它。”
模块之间如何交互
“启动时组装,入口层把内置工具和 MCP 工具合并注册进同一张工具注册表,交给 Agent。”
“运行时流转,Agent 持有消息历史,每轮把历史交给模型层,模型返回的调用请求交给工具层执行,结果回填历史,循环往复。”
“关键的设计决定是,所有模块对外只输出一种东西,统一格式的流式事件。文本增量、思考增量、工具调用、用量统计,都是事件,入口层只管渲染,不掺和逻辑。”
“好处是每层都能单独替换。换模型只动模型层,加工具只动注册表,改界面只动入口层。”
八股延伸|Agent 是怎么调用工具的?
Agent 是怎么调用工具的?
“标准的 Function Calling 机制。”
核心循环是一个 while(true) 的 ReAct 循环。
每一轮迭代,Agent 把当前的对话历史连同工具定义列表一起发给 LLM。
LLM 的响应有两种可能:如果它认为需要调工具,就在响应里返回 toolCalls 字段,包含工具名称和 JSON 参数;如果它觉得信息够了,就直接返回自然语言回复。
“收到 toolCalls 后,Agent 把它们封装成调用请求,交给工具注册表去调度执行。”
工具注册表内部维护了一个 ConcurrentHashMap,key 是工具名称,value 是工具定义和执行器。
执行完毕后,结果以 tool 角色的消息追加到对话历史,然后进入下一轮迭代,LLM 根据工具结果继续推理。
“并行调用也支持。如果 LLM 一次返回多个 toolCalls,工具注册表会用一个固定大小的线程池并行执行,最多同时跑 4 个工具。每个工具有独立的超时控制,单个工具 60 秒,整个批次 90 秒。”
还有其他调用方式吗?
“有。MCP 工具走的是另一条路径。”
MCP 工具不是在 Agent 进程内部执行的,而是通过 JSON-RPC 2.0 协议发送请求到外部的 MCP Server 进程。
Agent 端有一个专门的管理模块统一管理所有 MCP Server 的生命周期,包括启动、初始化握手、工具发现、调用、关闭。
“但从 LLM 的视角看,MCP 工具和内置工具没有区别。”
这个管理模块在启动时会把每个 MCP Server 暴露的工具注册到同一个工具注册表里,工具名格式是 mcp__serverName__toolName。
LLM 不关心工具是内置的还是 MCP 的,它只看工具定义列表里的名称和参数描述。
八股延伸|Agent 怎么去判断是否需要调用工具?
Agent 怎么去判断是否需要调用工具?
“是 LLM 自己判断的。Agent 不做任何‘要不要调工具’的硬编码判断。”
每一轮 ReAct 循环,Agent 把所有可用的工具定义传给 LLM,LLM 在深度推理之后自己决定:这一轮需要调什么工具、传什么参数,还是直接回复用户。
“Agent 只做两件事:一是把工具的名称、描述和参数 schema 组装成标准的 JSON 格式传给 LLM,让它理解每个工具能做什么;二是忠实执行 LLM 返回的 toolCalls,不加干涉。”
“这就是 Function Calling 的核心理念——工具选择权交给模型。Agent 是执行者,不是决策者。”
八股延伸|工具调用失败时,怎么保证 Agent 不死循环或乱回答?
工具调用失败时,怎么保证 Agent 不死循环或乱回答?
“不会,有三道防线。”
第一道是循环预算机制,它在每轮循环开始前做预算检查。
预算机制追踪三个维度:累计 token 消耗、已执行的迭代次数、以及死循环检测。
死循环检测的原理是观察最近几轮是否在重复相同的工具调用模式——如果连续几轮调同一个工具、传同样的参数,直接判定为停滞,强制退出循环并返回错误信息。
“第二道是工具层面的异常处理。”
“工具注册表在执行工具时把所有异常分成三类:策略拒绝(路径越权、命令被禁)返回’策略拒绝’前缀的消息;普通异常返回’工具执行失败’前缀的消息;工具超时返回’工具执行超时,已取消’。”
无论哪种情况,异常都被捕获并转成自然语言字符串,作为 tool 角色的消息返回给 LLM。LLM 看到失败消息后会自行决定:换一种方式再试,还是直接告诉用户操作失败。
“第三道是多 Agent 模式下的 Reviewer 审查。如果用的是多 Agent 协作模式,每个步骤执行完后会经过 Reviewer 审查。Reviewer 判定不合格的步骤最多重试 2 次,超过次数保留当前结果并标记步骤状态,不会无限重试。”
追问补强|模型生成的参数不合法,责任在哪层、怎么处理?
三层。第一层靠 schema 约束:工具定义带 JSON Schema 参数说明,模型照着填,错误率本身低。第二层是客户端校验:非法 JSON、参数不合法在这里拦下——不能把坏参数直接喂给执行器,执行器抛异常是进程级问题而不是"给模型的错误信息";安全敏感工具上错误参数本身就是攻击面(路径穿越、命令注入),靠围栏拒绝而不是回喂。第三层才是把结构化错误作为 tool result 回喂,模型下一轮通常能自纠。但不能指望自愈兜底一切:选错工具、缺硬依赖这类错误模型修不了,要设重试上限,超限熔断或降级。