不是再包一层 Tool Calling,而是把异步真正做进 Agent Runtime:loopa
如果你做过一点 Agent,很快就会遇到一个尴尬的现实:
很多项目都能演示"模型调用一个工具,然后返回答案",但一旦工具开始变复杂,尤其是出现异步任务时,整套设计就开始失真。
问题通常会从这里开始:
- 一个回合里不止一个工具调用
- 有些工具秒回,有些工具要跑几秒甚至更久
- 有些工具在本地执行,有些工具来自远端 MCP server
- MCP 的异步任务需要轮询状态
- 如果让模型自己轮询,会不断消耗 token
loopa 就是围绕这些问题写的一个 Go Agent Runtime。
它不是一个"把 OpenAI function calling 再封装一层"的 demo,而是把 本地工具、MCP 工具、同步调用、异步调用、会话摘要 放进同一个运行时里,重点解决一个核心问题:
异步工具到底应该怎么进入 Agent Runtime,而不是只停留在概念上。
项目地址:
- GitHub:
https://github.com/minhyannv/loopa
为什么"异步"才是真正的分水岭
同步工具并不难理解:
- 模型说要调工具
- runtime 执行工具
- 立刻拿到结果
- 再把结果回给模型
这条链路很直。
但异步工具一出现,事情就完全变了。
比如这些场景:
- 本地端口扫描
- 远端排队任务
- 需要持续几轮才能完成的 MCP tool
- 需要取消、超时、轮询状态的长任务
这时你就必须回答几个工程问题:
- 异步工具是否要暴露成另一套接口?
- 一个回合里多个异步工具怎么并发启动?
- 工具结果返回给模型时,顺序怎么保证?
- MCP Tasks 的轮询由谁负责?
- 超时或取消时,是谁去通知远端任务结束?
很多"Agent 示例"不会真的碰到这些问题,所以看起来简单。一旦要做成真正能用的 runtime,这几件事就绕不过去。
loopa 的核心思路
loopa 的设计很直接:
- 模型只负责决策
模型决定是否调用工具、调用哪个工具 - runtime 负责执行
runtime 负责同步调用、异步调度、MCP 轮询、取消、摘要 - 本地工具和 MCP 工具统一接入
最终都变成统一的 tool surface - 同步和异步统一接入
loop 不需要知道"这是本地还是远端",只需要知道"它是 sync 还是 async"
这四件事放在一起,项目的边界就清楚了:
- 模型做语义层决策
- 运行时做调度层执行
真正麻烦的那部分不是"让模型调工具",而是"让运行时把异步工具跑完整"。
这个项目最值得看的地方:本地异步和 MCP 异步是统一的
很多系统会把这两类异步拆成两套逻辑:
- 本地异步工具一套
- MCP 异步任务一套
loopa 不是这么做的。
它的做法是:
- 本地异步工具实现
AsyncTool - MCP 上需要走 task 的工具,也被适配成
AsyncTool AgentLoop只认Execute/ExecuteAsync
这意味着对 loop 来说:
port_scan是异步工具mcp__remote__slow_echo也是异步工具- 它不需要知道这两者底层一个是本地 goroutine,一个是 MCP Tasks
这就是这个项目最核心的工程价值:
异步不是某一种 transport 的特性,而是 runtime 的一级抽象。
异步工具在 loopa 里是怎么跑起来的
第一步:模型返回 tool calls
loopa 仍然基于 chat.completions 做模型调用。
每轮里,模型拿到:
- 当前消息列表
- 当前可用工具 schema
然后返回:
- 普通文本
- 或者 tool calls
如果返回的是 tool calls,AgentLoop 就进入工具执行阶段。
第二步:同步工具立即执行,异步工具先启动
这一段是整个 runtime 的关键。
AgentLoop 的策略不是"拿到一个工具调一个工具",而是:
- 遍历本轮所有 tool calls
- 同步工具立即执行
- 异步工具先全部启动
- 再按原始顺序等待异步结果
这么做有两个好处:
- 异步工具能真正并发起来
- 回填给模型的 tool message 顺序仍然稳定
这个点非常重要,因为大模型上下文是顺序敏感的。
如果结果顺序飘来飘去,模型行为也会变得不稳定。
第三步:把工具结果统一回填给模型
无论工具是:
- 本地同步
- 本地异步
- MCP 同步
- MCP 异步
最后都会被转换成普通的 tool result message,重新送回下一轮模型请求。
所以从模型视角看,世界很简单:
- 它提出一次工具调用
- 稍后收到一条工具结果
而中间那一整套同步 / 异步 / 轮询 / 取消 / transport 差异,全部由 runtime 吃掉。
MCP 异步为什么不能交给模型轮询
这是 loopa 里我最想强调的一点。
很多人第一次做 MCP Tasks,会自然想到:
"既然有任务状态,那就让模型继续调用一个查询状态的工具,不就行了吗?"
问题是这样做会把调度逻辑塞回模型:
- 模型要反复参与状态查询
- 每轮查询都会增加 token 成本
- 模型会被迫理解"不属于业务语义"的轮询流程
- 一旦状态多、轮询久,成本和复杂度都会上升
loopa 的做法正好相反:
- 模型只发起一次异步工具调用
- 宿主在后台做
tasks/get - 到终态后由宿主取
tasks/result - 最后只把最终结果返回给模型
这意味着 MCP 异步在这里不是"多轮模型交互",而是"单次模型调用 + 宿主后台完成任务管理"。
这是一个非常重要的边界划分。
MCP task 流程图
渲染错误: Mermaid 渲染失败: Parse error on line 7: ...Server Model->>Loop: tool call ---------------------^ Expecting '+', '-', 'ACTOR', got 'loop'
注意这里真正负责轮询的是 Client,不是模型。
loopa 是怎么判断一个 MCP 工具该同步还是异步的
这个项目还有一个比较工程化的点:
它不是在执行时才临时决定工具类型,而是在加载阶段就定下来。
具体判断依据有两层:
- MCP server 是否声明了
tasks.requests.tools.call - 每个工具自己的
execution.taskSupport
然后在加载阶段直接完成映射:
required-> 异步工具optional-> 如果 server 支持 task,就按异步工具处理- 缺省 /
forbidden-> 同步工具
这样做的好处是:
- 注册后的工具语义明确
AgentLoop不需要在执行时再临时猜路径- 本地工具和 MCP 工具统一暴露成相同接口
技术结构为什么是现在这样
如果用一句话概括:
main.go负责装配agent/负责控制流tools/负责统一工具接口mcp/负责远端工具接入和 task 轮询
总体结构
User Input
RunInteractive
AgentLoop
Session
OpenAIChoiceCreator
chat.completions
Tool Registry
Local Tools
MCP Loader + Clients
stdio
Streamable HTTP
各模块职责
agent/loop.go
管理模型与工具之间的迭代执行agent/session.go
管理消息历史、摘要状态和 system prompt 重建tools/registry.go
统一注册和分发工具mcp/loader.go
发现 MCP 工具,并决定它们是 sync 还是 asyncmcp/task.go
负责异步 task 的轮询与结果获取
这个拆法的关键在于:
异步逻辑不写在一堆分散的 if/else 里,而是被收敛到明确的运行时边界。
自动摘要为什么在这个项目里也很重要
如果一个 runtime 真要连续交互,除了异步,另一个必须面对的问题就是上下文膨胀。
loopa 采用了比较务实的策略:
- 超过阈值就摘要
- 老消息被压缩
- 最近几轮真实用户消息保留原样
- system prompt 始终根据最新 summary 重建
这跟异步能力其实是同一类问题:
- 都不是"模型本身的能力"
- 都是 runtime 层必须接住的工程问题
所以这个项目真正做的事情,不只是把工具接上,而是把 Agent 在长期运行时一定会遇到的问题 放进了一个统一设计里。
这个项目的工程取舍
1. 不上重型框架
这个项目没有依赖复杂的 agent framework。
不是因为框架不好,而是因为这里更想把 runtime 的关键边界写清楚:
- 模型做什么
- runtime 做什么
- 异步由谁调度
- 轮询由谁负责
如果这些边界一开始就是黑盒,很难真正理解 Agent Runtime 到底在解决什么问题。
2. 继续使用 chat.completions
虽然现在也有 Responses API、background mode 这些路线,但 loopa 目前仍然围绕 chat.completions 构建。
这样做的好处是:
- 更容易接 OpenAI-compatible provider
- 更容易理解 runtime 自己承担了哪些控制逻辑
代价也很明确:
- tool 调度要自己写
- MCP task 轮询要自己写
- 摘要触发与消息重建也要自己写
但对一个"想把运行时做明白"的项目来说,这反而是优点。
3. debug 日志不是附属品
异步一多,没有日志几乎没法排查。
所以 loopa 会在 debug 级别输出这些信息:
- OpenAI 请求摘要
- 每轮 loop 的迭代信息
- 工具参数与结果摘要
- MCP 连接、初始化、工具发现
- task 创建、轮询状态、最终结果、取消
- 自动摘要触发原因
一旦异步出问题,你最想知道的永远是:
- 卡在模型侧了吗?
- 卡在工具启动了吗?
- 卡在 task 轮询了吗?
- 卡在结果回填了吗?
这些问题如果没有日志,runtime 基本不可维护。
怎么验证它真的把异步做进去了
这也是这个项目比较完整的地方。它不是只有单元测试,而是把异步路径分层测了出来。
第一层:普通自动化测试
bash
go test ./...
第二层:工具链路测试
覆盖四种组合:
- 本地同步工具
- 本地异步工具
- MCP 同步工具
- MCP 异步工具
也就是说,项目不是只测"能不能调一个工具",而是明确覆盖:
本地 / MCP × 同步 / 异步
第三层:真实模型 E2E
通过:
bash
RUN_LIVE_MODEL_E2E=1 go test ./e2e -v
可以让真实模型参与整条链路:
- 模型识别语义并选择工具
- runtime 执行同步或异步工具
- MCP task 在宿主侧轮询完成
- 最终结果回填给模型
这让"异步是不是只是纸面设计"这个问题,有了真正的端到端验证。
这个项目适合谁看
我觉得它特别适合下面几类人:
- 想自己写 Go Agent Runtime 的人
- 想把 MCP 真正接到本地运行时里的人
- 想研究同步 / 异步工具统一抽象的人
- 想理解"模型调度"和"宿主调度"边界的人
如果你追求的是"直接拿来就能用的全家桶框架",它未必是那个方向。
但如果你想认真理解:
- 为什么异步工具会改变 runtime 设计
- 为什么 MCP Tasks 不应该交给模型轮询
- 为什么运行时必须显式管理上下文和日志
那这个项目就很值得看。
结语
loopa 最有价值的地方,不是"它也支持 tool calling",而是它把 异步 放到了设计中心。
它回答的不是一个 API 问题,而是一个 runtime 问题:
- 本地异步怎么做
- MCP 异步怎么做
- 两者怎么统一
- 谁来轮询
- 谁来取消
- 结果怎么稳定地回填给模型
如果你真的想把 Agent 从 demo 推到工程实现,这几个问题迟早都要面对。
而 loopa 的价值,就在于它已经把这些问题落成了一套可以跑、可以测、可以继续扩展的 Go Runtime。
项目地址:
- GitHub:
https://github.com/minhyannv/loopa