echo-agent 前身为 2025 年 11 月启动的个人助理项目 fubot,最初面向长期陪伴型个人智能体,围绕认知记忆、上下文延续、用户偏好沉淀、任务闭环与持续自我优化展开。随着真实场景迭代,项目逐步形成多入口接入、统一事件模型、消息总线、Agent Loop、多模型抽象、工具调用、MCP 接入、任务调度、权限审批、运行轨迹、长期记忆和受控自演进等能力。目前已支持微信、QQ、CLI、Gateway、Webhook、Cron 等入口,服务用户超过 20 万、累计下载超过 50 万,是面向长期运行、记忆增强和可持续成长智能体的开源 Agent Runtime。

你让 Agent "帮我修复测试失败"。一个普通聊天模型可能会直接猜原因,或者给你一套通用排查清单。
但一个真正能工作的 Agent,应该先看仓库、读日志、运行测试、判断失败点,必要时修改代码,再验证结果。这里的关键不是"模型会不会说出工具名",而是模型、工具、权限、观察和终止条件能不能组成一条稳定闭环。
本篇只讲一个点:InferenceStage 的职责不是调用模型,而是把模型输出的行动提案转化为受控、可回写、可终止、可审计的推理循环。
问题入口
很多最小 Agent demo 会写成这样:
ini
response = llm.chat(messages, tools=tool_defs)
if response.tool_calls:
result = execute_tool(response.tool_calls[0])
messages.append(result)
response = llm.chat(messages, tools=tool_defs)
这段代码能解释 tool calling 的基本形式,但离生产级 Agent 还很远。
真实系统里,模型可能连续调多个工具;某个工具可能持续失败;工具参数可能越权;写文件、执行命令、发送消息这类动作可能产生副作用;工具输出可能很长;模型可能陷入同参重复调用;provider 也可能在流式输出到一半时失败。
如果这些问题都靠"提示词里让模型谨慎一点"解决,Agent 的安全边界就退化成了模型自觉。
会调用工具只说明有行动接口;能否算 Agent,要看工具调用是否进入闭环、是否受权限约束、是否能被追踪和复盘。
为了不停留在抽象层面,下面以 echo-agent 的实现为例。上一阶段 ContextStage 已经把消息、工具定义、任务类型、计划和流式发布器装进 PipelineContext。InferenceStage 接过的不是零散参数,而是一份已经构造好的推理快照。
它最终返回 InferenceResult:最终文本、工具调用次数,以及是否建议响应阶段触发技能或记忆复盘。它不负责保存最终回答,也不负责后台整理。InferenceStage 做推理与行动,ResponseStage 做收尾。
Agent Loop
推理循环的基本结构并不复杂。每一轮先根据熔断状态过滤工具,再调用模型;如果模型没有工具调用,就结束;如果模型请求工具,就执行工具,把结果作为 tool 消息写回上下文,然后进入下一轮。

可以把它压成一段伪代码:
ini
async def run(ctx):
messages = ctx.messages
response_text = ""
repeat_tracker = {}
for iteration in range(max_iterations):
unavailable = circuit_breaker.get_unavailable_tools()
tools = filter_out(ctx.tool_defs, unavailable)
response, route = await chat_stream_with_routing(
messages=messages,
tools=tools,
on_delta=ctx.stream_publisher.on_delta,
)
issues = inference.validate_response(response)
trace_llm(iteration, route, response.finish_reason, issues)
if response.finish_reason == "error":
return fallback_or_last_text(response_text)
if response.content:
response_text = response.content
if not response.has_tool_calls:
mark_plan_complete_if_needed(ctx)
return response_text
append_assistant_tool_calls(messages, ctx.session, response)
for tool_call in response.tool_calls:
result = await run_tool_with_guards(ctx, tool_call, repeat_tracker)
append_tool_result(messages, ctx.session, tool_call, result)
return fallback_or_last_text(response_text)
这段伪代码的重点不是函数名,而是控制权位置:模型只提出下一步,循环由系统推进。模型说"我要调用工具",并不等于系统已经决定执行工具。
这就是 ReAct 思想在工程里的落地:模型产生行动意图,系统执行行动,行动结果回到模型上下文,模型再继续推理。不同的是,生产实现必须在每个箭头上加边界。
行动提案
Agent 架构里必须区分"模型输出"和"系统命令"。tool call 更准确地说是行动提案:模型根据当前上下文判断某个工具可能有助于完成任务,但这个提案还要经过系统校验。
如果把模型输出直接当命令执行,Prompt Injection、误解用户意图、错误参数和危险副作用都会被放大。专业系统要在模型之后建立独立决策层。
echo-agent 的转换链路是:
| 环节 | 职责 |
|---|---|
| 模型 | 基于上下文提出工具调用 |
InferenceController |
校验响应约束,如工具白名单、黑名单、JSON 输出要求 |
ApprovalGate |
判断本次工具调用是否允许执行 |
ToolRegistry |
参数校验、超时、重试、replay 防护和审计 |
messages/session |
保存 assistant 工具请求与 tool 结果 |
| 下一轮模型 | 读取观察,决定继续行动或生成最终回答 |
这里有一个容易忽略的细节:模型响应校验不是都强制失败。当前实现发现问题后主要记录 warning,例如要求必须调工具但模型没调、调用了 blocked tool、要求 JSON 却返回非 JSON。这是一种温和约束,先提供可观测信号,再由更严格场景决定是否重试、修复格式或直接失败。
工具事务
从工程角度看,每一次工具调用都像一个小事务。它有输入、权限检查、执行上下文、结果、日志,以及可能的副作用。
只读工具风险较低,但仍可能泄露敏感信息或造成高成本访问。写入工具会改变文件、记忆、任务或技能,需要路径和权限约束。执行类工具可能启动进程、访问网络、修改系统状态,需要更强审批。外部可见动作,例如发送消息、安装技能、创建调度任务或调用不可信 MCP 工具,更需要审计和人工确认。
所以 echo-agent 不会在模型请求工具后直接执行,而是先进入 ApprovalGate。审批会综合静态 guard、权限策略、风险分类、通道信任、自动批准、approval mode、allowlist、unattended 模式、smart approval 和 manual approval flow。
如果审批被拒绝,系统不会把它当异常炸掉,而是把拒绝信息作为 tool 结果写回上下文。模型下一轮能看到"这个动作被拒绝了",然后解释原因、调整方案,或者请求用户确认。
审批通过后,系统构造 ToolExecutionContext。这里面包括 execution_id、trace_id、session_key、user_id、credentials、approved_actions、allowed_tools,以及一个关键字段:idempotency_key。
幂等键用于阻止副作用工具被重复执行。构造方式大致是把 trace_id、工具名、工具序号和排序后的参数哈希到一起:
python
def build_idempotency_key(trace_id, tool_name, index, params):
payload = json.dumps(params, sort_keys=True, ensure_ascii=False)
digest = sha256(f"{trace_id}:{tool_name}:{index}:{payload}".encode())
return digest.hexdigest()[:24]
这不是形式主义。写文件、执行命令、发送消息、修改外部系统,重复执行一次就可能造成真实损害。幂等键让 ToolRegistry 能识别同一执行范围内的 replay,并阻止副作用重复发生。
执行内核
ToolRegistry.execute 不是一个普通字典查找。它是工具执行内核。
它会先解析工具别名,例如 bash 映射到 exec,run_code 映射到 execute_code。然后检查执行上下文里的 allowed_tools,避免多 Agent 或受限 worker 越过自己的工具白名单。
接着它会确认工具存在,执行参数 schema 校验。对于副作用工具,它会检查 replay cache;真正执行时用 asyncio.wait_for 包裹,受 timeout_seconds 限制;失败后按 max_retries 重试。
执行日志还会记录工具名、脱敏参数、执行 ID、trace ID、开始与完成时间、成功状态和尝试次数。key、token、secret、password、api_key 这类敏感字段会被掩码。
这也是为什么工具调用应该被视为事务,而不是函数调用。函数调用关心返回值;工具事务还要关心权限、幂等、超时、重试、审计和副作用。
观察回写
工具结果不是普通文本,而是新的环境观察。模型需要根据它决定下一步,系统也需要保留它与具体工具调用之间的关系。
echo-agent 在模型返回工具调用后,会先把带有 tool_calls 的 assistant 消息加入 messages,同时写入 session。工具执行后,再追加对应的 tool 消息,并带上 tool_call_id 和工具名。
这有两层意义。
第一,许多模型 API 要求工具结果必须跟在对应的 assistant tool call 后面,否则消息结构非法。第二,会话历史能恢复完整行动轨迹:模型为什么调工具、调了哪个工具、参数是什么、工具返回了什么。
工具输出还会在写入前截断,当前上限是 16000 字符。长日志不应原样塞进上下文,否则后续推理会被噪声淹没,甚至挤掉当前目标和安全约束。若需要完整原始结果,工具应把原文写入文件、对象存储或审计日志,再把摘要和路径返回给模型。
工具结果不是回答素材的散装文本,而是带来源、带关联、带成功失败语义的环境反馈。
这能解释为什么 ToolResult 需要结构化表达。成功、失败、错误信息、数据载荷、元数据和截断策略,都会影响下一轮推理质量。观察越清楚,模型越不容易把失败当成功、把噪声当证据。
终止边界
没有终止条件的 Agent 不是更自主,而是不可控。推理阶段越强,停止机制越重要。

echo-agent 的推理循环有多层刹车。
| 边界 | 作用 |
|---|---|
max_iterations |
防止模型无限自我驱动 |
| circuit breaker | 持续失败的工具暂时从可见工具列表移除 |
| repeat tracker | 同名同参数工具调用达到阈值后阻断 |
| ApprovalGate | 高风险或越权动作在执行前被拒绝 |
| provider fallback | 候选模型失败时尝试后备链 |
| finish_reason error | provider 不可恢复错误时退出并返回兜底 |
| 工具超时与重试 | 避免工具长时间挂起或瞬时失败直接中断 |
重复调用尤其值得单独看。模型连续调用同一工具、同一参数,通常说明它没有从反馈中获得新信息,或者没有理解工具结果。echo-agent 使用 _repeat_tracker 记录工具名和参数哈希;同参重复达到阈值后,会把最后一条工具消息替换成 blocked 提示,让模型知道必须换策略或停止。
工具熔断解决的是另一类问题:某个工具持续失败时,不要继续暴露给模型。模型并不知道外部 API 是否故障、命令执行器是否不可用、配置是否缺失。熔断不是删除工具,而是在恢复窗口内暂时隐藏;成功执行记录 success,失败执行记录 failure。
模型路由也有边界。启用 ModelRouter 后,系统会根据任务类型、内容和配置生成候选链,RouteDecision 包含 provider、model、fallback_chain、reason、context_window、max_tokens 和 temperature。若某个候选模型已经通过流式输出向用户发出增量,后续再失败,当前实现不会静默切到另一个模型继续输出,避免用户看到两个模型混合生成的回答。
可观测性
生产级 InferenceStage 还必须解释自己做过什么。
echo-agent 会为模型调用创建 llm_{iteration} span,为工具调用创建 tool_{iteration}_{tool_index} span。span metadata 里记录模型、provider、路由原因、finish reason、工具名和执行结果。若启用 OpenTelemetry,还会记录对应工具 span。
这样一次任务可以被拆成:
process_message
llm_0
tool_0_0
llm_1
tool_1_0
llm_2
当回答出错时,团队可以判断问题来自模型路由、工具失败、审批拒绝、重复调用阻断,还是最大迭代耗尽。
流式输出也被放在正确边界内。InferenceStage 只把模型 delta 交给 ctx.stream_publisher.on_delta,真正的段落刷新、句子刷新、通道发布和最终消息合并由流式发布器与后续阶段处理。工具密集型任务里,用户可以看到进度事件,例如 Using tool: exec,但不应把中间半成品误认为最终回答。
生产可用性
判断一个推理阶段是否生产可用,不能只问"能不能调工具"。更可检验的标准是:
| 检查项 | 可检验标准 |
|---|---|
| 行动闭环 | assistant tool call 与 tool result 成对写入上下文和会话 |
| 权限治理 | 工具执行前经过 ApprovalGate,区分只读、写入、执行和高风险动作 |
| 副作用保护 | 副作用工具有幂等键、replay cache、审批记录 |
| 失败恢复 | 参数错误、审批拒绝、provider 错误能转为可理解状态 |
| 循环控制 | 有最大迭代、重复调用阻断、工具熔断和取消路径 |
| 结果治理 | 工具输出截断,完整结果通过文件或审计日志保留 |
| 路由解释 | provider、model、fallback 原因和失败状态可追踪 |
| 用户体验 | 长任务有非最终进度事件,最终回答由 ResponseStage finalize |
| 回归评估 | 有工具调用 trace、失败样例、循环耗尽样例和权限拒绝样例 |
这里的核心判断很简单:模型负责提出行动方向,系统负责判断行动能不能执行,工具负责接触环境,观察再反馈给模型。
如果缺少系统控制层,Agent 只是模型的外接手脚;如果缺少观察回写,工具只是一次性插件;如果缺少终止与审计,行动能力越强,线上风险越高。
小结
InferenceStage 是 Agent 行动能力的中心。它承接 ContextStage 构造好的世界,让模型在其中提出行动提案,再把提案交给审批、工具执行、结果回写、循环终止和可观测系统共同治理。
理解这一层后,Agent 的核心不再是"模型会不会调用工具",而是"每次工具调用是否减少了不确定性,是否遵守权限边界,是否留下可复盘轨迹,并在合适时机收敛"。
这也是从 Chatbot 到 Agent 的关键跃迁:回答不再只是一次语言生成,而是在有限成本和明确边界内,把不确定推理逐步变成可校验观察。
(全篇完)
本文为 echo-agent 设计笔记系列第 09 篇。项目源码已开源至 GitHub。如果你对工业级 Agent 的工程落地感兴趣,欢迎加入技术交流群(QQ群号:47572014)参与日常讨论。下一篇我们将探讨 《ResponseStage 设计笔记:回答落盘与后台整理》,敬请期待。