首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >一次工具调用要过十道关:Agent 运行时和 Agent 框架的分工到底在哪里

一次工具调用要过十道关:Agent 运行时和 Agent 框架的分工到底在哪里

原创
作者头像
用户5195948
发布2026-09-08 23:52:45
发布2026-09-08 23:52:45
60
举报

判断一个东西是框架还是运行时,我后来用的是这么一条土办法:看它在你不写代码的时候还管不管事。

框架管的是你写代码的那段时间——怎么描述一个 agent、怎么串工具、怎么组织提示词。运行时管的是代码写完之后的每一次执行——这一下允不允许、记不记得住、超时了算谁的、出了事能不能回放。

这条界线听起来很虚,但它在代码里是有具体位置的。本文就是把这个位置指出来:在我们的仓库里,它落在 ToolPolicyGateway.invoke() 这个方法上,一次工具调用要在这里过十道关。

先看结论

如果只看一段,看这张表:

Agent 框架

Agent 运行时

管的时间

你写代码的时候

代码写完之后的每一次执行

核心问题

agent 怎么想、怎么组织、怎么调用

这一下允不允许、记不记得住、算谁的账

典型能力

提示词组织、链/图编排、模型与工具的封装、开发期体验

权限判定、密钥边界、出网策略、台账与重放、成本归属、审批中断

衡量它好不好的标准

表达能力、上手速度、生态里有多少现成件

出事之后能不能查清、能不能拦住、能不能重放

在我们仓库里对应哪一块

modules/agent/runtime/(planner + executor + verifier)

kernel/ports/kernel/runtime/kernel/security/

体量

239 行

光一个工具端口的策略网关就 475 行

这篇文章要论证的就是这张表的最后两行——它不是我画出来的示意图,是 wc -l 数出来的。

一、这个问题为什么值得单独写一篇

「Agent 平台」这个词现在至少吃掉了三层东西:写 agent 的开发库跑 agent 的执行引擎管 agent 的治理面。三层混在一个词里,于是每次讨论都变成鸡同鸭讲——一方在说「我用它三行就搭出一个 agent」,另一方在说「我要知道昨天下午三点那次调用是谁批的」。两句话都对,但根本不在回答同一个问题。

我不打算去评判任何一个具体框架的内部实现——那需要我读透别人的代码才有资格说,而这篇文章里的每一条我都要能给出行号。所以本文只做两件事:

  1. 描述「框架这一层通常负责什么」——这部分是共识,不涉及对某个项目的断言;
  2. 拿我们自己的仓库当样本,把「运行时这一层负责什么」定位到具体文件。

样本是 github.com/soit-ai/soit,Apache 2.0,本文所有行号对应提交 3a57ae1

二、先数自家的 agent 循环:239 行

一个 agent 的核心循环,抽象出来无非三件事:想下一步(plan)、做这一步(execute)、看看做完了没(verify)。在我们仓库里这三件事各占一个文件:

代码语言:plaintext
复制
server/app/modules/agent/runtime/planner.py    98 行
server/app/modules/agent/runtime/executor.py   42 行
server/app/modules/agent/runtime/verifier.py   99 行

加起来 239 行。executor 那 42 行几乎可以整个贴出来:

代码语言:python
复制
class AgentExecutor:
    """Execute tool actions for agent."""

    def __init__(self, tool_port: ToolPort):
        self.tool_port = tool_port

    async def execute_tool(
        self, tool_ref, parameters, ctx, run_id, tool_call_id,
        idempotency_key, run_step_id=None, resume_approval=False, lease_owner=None,
    ) -> ToolResponse:
        """Execute tool call."""
        return await self.tool_port.invoke(
            tool_ref=tool_ref, parameters=parameters, run_id=run_id,
            tool_call_id=tool_call_id, idempotency_key=idempotency_key,
            run_step_id=run_step_id, resume_approval=resume_approval,
            lease_owner=lease_owner, ctx=ctx, strict_registry=True,
        )

这个类没有任何逻辑,它只是把参数原样交给 tool_port。这不是偷懒——它恰恰是这篇文章的主张:执行的规矩不写在循环里,写在端口上。

planner 那 98 行也一样朴素:把消息交给模型,用的是模型原生的 function calling,回来要么是 tool_calls,要么是一段文本,没有第三种情况:

代码语言:python
复制
response = await self.llm_port.chat(
    messages=planning_messages, model=model, temperature=temperature,
    tools=tool_definitions if tool_definitions else None,
    tool_choice="auto" if tool_definitions else None,
    run_id=run_id, reasoning_effort=reasoning_effort,
)
if response.tool_calls:
    return PlanResult(action="tool", tool_calls=response.tool_calls, ...)
return PlanResult(action="respond", response=response.text or "", ...)

没有提示词模板 DSL,没有链或图的描述语言,没有靠正则去解析模型输出里的 Action: 段。verifier 那 99 行同理:它用一个结构化输出的工具定义(verify_response,两个字段 okreason)问模型「这答复够不够」,仅此而已。

这一层是框架的主场,而我们在这一层刻意做得很薄。 薄不是优点也不是缺点,它只是说明:我们没打算在这里跟谁比表达能力。

三、那么,包着这 239 行的 1617 行在干什么

server/app/modules/agent/application/service.py 是 1617 行。它的 import 列表其实已经把答案说完了:

代码语言:python
复制
from app.kernel.identity.guard import workspace_guard
from app.kernel.ports.approvals import ApprovalLedgerPort, ApprovalRecord
from app.kernel.ports.common.rate_limiter import RateLimiter
from app.kernel.runtime.runs.tool_calls import RuntimeToolExecutionService, ToolExecutionCommand
from app.kernel.runtime.runs.writer import TraceWriter
from app.kernel.runtime.tools.approval import tool_approval_rule
from app.kernel.runtime.tools.resolver import ToolResolver

工作区守卫、审批台账、限流器、工具执行台账(带租约与幂等)、trace 写入器、审批规则、工具解析器。再加上文件里那个专门用来做人工审批中断的内部异常:

代码语言:python
复制
class _AgentApprovalInterrupt(Exception):
    """Internal control signal for a durable human approval checkpoint."""

而真正的循环只有一行 whileservice.py:653):

代码语言:python
复制
while pending_tool_calls or iterations < data.max_iterations:

循环本身一行,循环之外一千六百多行。 这一千六百行里绝大部分不在解决「agent 怎么想」,而在解决「这一下允不允许、记不记得住、算谁的账、中断了怎么接着来」。

这就是两层的体量比。如果一个东西九成的代码都花在后面这组问题上,把它叫做「又一个 agent 框架」,描述的其实是它最不重要的那一成。

四、分界线的确切位置:一次工具调用要过十道关

上面说「规矩写在端口上」,端口具体是什么?

server/app/kernel/ports/tools/interface.py 一共 54 行,定义了一个抽象类 ToolPort,只有一个抽象方法 invoke()。这是接口。

真正干活的是 server/app/kernel/ports/tools/policy.py 里的 ToolPolicyGateway,475 行,它实现的是同一个 ToolPort 接口。也就是说:对调用方来说,「裸调工具」和「过治理调工具」长得一模一样,换的只是注入哪个实现。

ToolPolicyGateway.invoke()policy.py:231 开始,一次工具调用依次经过:

关卡

干什么

位置

1

密钥解析与脱敏

参数里带 secret 引用就去 secrets_port 换成真值,同时产出一份脱敏副本供后续记录

policy.py:253–256

2

台账认领

在运行台账里认领这次调用,生成或复用 tool_call_id 与幂等键

policy.py:264–290

3

幂等重放

认领结果标记为 replayed 时直接返回缓存响应,不再真的调一次

policy.py:291–296

4

租约

给这次执行挂一个租约(max(60, 超时+10) 秒),执行过程中续租

policy.py:274policy.py:329

5

出网策略

把参数里所有 http URL 抠出来,逐个过 check_egress_policy

policy.py:307–309

6

速率限制

tool_ref + 租户 + 工作区 + 用户 做每分钟限流

policy.py:311–318

7

日配额

tool_ref + 租户 + 工作区 做 86400 秒窗口的配额

policy.py:319–324

8

链路追踪

开一个 soit.tool.invoke 的 OTel span,带上租户、工作区、run、step 属性

policy.py:336–348

9

超时与重试

统一的超时重试;带幂等键时 max_retries=1,注释写明这个边界上是 at-most-once

policy.py:349–360

10

审计与结账

写审计日志(记的是脱敏后的参数)、更新步骤状态与指标、记成本

policy.py:367–383410–425

注意第 1 关和第 10 关的配合:真值只进入调用,脱敏副本才进入记录。代码里为此专门维护了 resolved_parametersredacted_parameters 两份数据,审计与指标一律用后者。这类事情很难在写业务代码时靠自觉做到,只能靠它站在必经之路上。

第 9 关的注释也很说明问题:

代码语言:python
复制
# Durable Agent calls are at-most-once at this boundary.
# Not every downstream adapter can honor an idempotency key.

这就是运行时层要处理的那类问题——它不关心你的 agent 逻辑写得漂不漂亮,它关心「下游不支持幂等键的时候,一次重试会不会把工单开两遍」。

五、一个能证明「治理不在循环里」的事实

读到这里你完全可以说:那也许只是你们把治理代码塞进了 agent 循环的下游而已。

所以看第二个执行模型。我们仓库里除了 agent 循环,还有一套工作流引擎(modules/workflow/,6269 行,含 969 行的 engine.py 和 855 行的 executor.py)。它是 DAG 式的,跟 agent 循环的执行模型完全不同。

那么它调工具走的是什么路?

代码语言:python
复制
# server/app/modules/workflow/runtime/executors/tool.py:428
response = await context.tool_port.invoke(...)

# server/app/modules/workflow/runtime/executors/llm.py:105
response: ChatResponse = await context.llm_port.chat(...)

同一个 tool_port.invoke,同一个 llm_port.chat

两套毫无关系的执行模型,共用一整套治理。这就是「分层」这个词的可操作定义:治理不是某个循环的属性,而是端口的属性;换掉上面的执行模型,下面这十道关一道都不少。

反过来说,这也正是「框架和运行时不冲突」的技术原因:能通过这些端口调用的,都能被治理——上面跑的是我们自己的循环、一个 DAG 引擎,还是别的什么东西,对下面这一层没有区别。

六、依赖清单是最诚实的定位声明

一个项目自称是什么,看 README;一个项目实际是什么,看依赖表。

server/pyproject.toml 的核心依赖里,跟 agent 相关的只有三个:

代码语言:toml
复制
"mcp>=1.28.1,<2",          # 工具协议
"ag-ui-protocol==0.1.19",  # 前端交互事件协议
"litellm==1.91.1",         # 模型调用

三个全是协议件或调用层,没有任何一个 agent 框架在核心依赖里。这不是宣言,这是 pyproject.toml 第 68 到 71 行。

那 LangChain 呢?在。但在这儿:

代码语言:toml
复制
[project.optional-dependencies]
local-embedding = [
    "sentence-transformers>=4.1.0",
    "langchain-huggingface>=0.0.6",
    ...
]

pyproject.toml:75–83,一个叫 local-embedding可选 extra,用途是本地跑嵌入模型,跟 agent 编排没有关系。

顺带做一次自我更正:我们 README 的技术栈表里,「LLM」那一行写着OpenAI · Anthropic · DeepSeek · Qwen · LangChain (adapter layer)README.md:240)。这一条和实际依赖对不上——LangChain 在我们这儿不是 LLM 适配层,只是一个可选的本地嵌入依赖。这属于文档不准确,我打算提一个 issue 去改;写这篇的时候还没提,所以正文里不挂链接。

七、入口收的是别人的协议,不是我们发明的形状

分层要成立,接缝就必须是公共的。三个接缝:

① 进来的请求——POST /api/v1/responses 直接接受 AG-UI 协议的 RunAgentInput

代码语言:python
复制
# server/app/api/v1/responses/router.py:222
async def create_response(payload: RunAgentInput | ResponseCreateRequest, ...):

前端不需要学一套 SOIT 专有的消息格式。

② 工具从哪来——工具引用是带命名空间的字符串,adapters/tools/router.py 里能看到三类前缀:tool:http:*(HTTP 工具)、tool:function:*(内置函数)、mcp_tool:*(走 MCP 适配器)。任何一个 MCP server 都能解析进工具注册表,不需要改代码。

③ 出去的事件——运行过程以 AG-UI 交互事件流的形式持久化并推给前端(adapters/agui/agent.pyresponses.py,共 909 行)。

三个接缝都是协议,不是我们的方言。这是「两层能拼在一起」的前提条件。

八、模型这一层我们同样没写框架

adapters/llm/ 一共 2619 行,其中 router.py 534 行。这 534 行做的是路由、凭据解析和出网守卫,不是提示词组织:

代码语言:python
复制
# server/app/adapters/llm/router.py:192(在 _authorize_provider_target 内)
await self.egress_guard.authorize(ctx, f"model-provider:{provider_slug}", url)

解析模型提供方的两条路径(router.py:368router.py:422)都要先过这一步。也就是说,连调模型这件事本身都要过出网策略——目标域名不在策略里,这次调用就出不去。

再看一条容易被忽略的:生产环境下,没有配凭据的模型提供方会被直接拒绝(router.py:316–324,错误码 MODEL_PROVIDER_CREDENTIAL_REQUIRED)。这类「生产模式拒绝你偷懒」的判断也是运行时层的典型职责——它不改善你的开发体验,它只是不让你把开发期的将就带到生产。

九、这条边界是被 CI 焊死的,不是文档里的口号

分层最常见的下场是写在文档里、烂在代码里。所以这一层我们交给了工具。

server/importlinter.ini 的第一条契约:

代码语言:ini
复制
[importlinter:contract:kernel_isolation]
name = Kernel is isolated
type = forbidden
source_modules =
    app.kernel
forbidden_modules =
    app.api
    app.modules
    app.adapters
    app.infra
    app.wiring

kernel 不许 import 任何上层。 治理内核一旦反向依赖了业务模块,「换掉上面的执行模型、下面一道关不少」这句话立刻就不成立了。所以这条不能靠自觉,得让 CI 去撞。

server/app/kernel/README.md 把这套规则写成了明文,其中一条值得单独引:

Kernel extension points that need product or infrastructure data must use provider interfaces registered from wiring/.

内核需要外部数据时不许直接伸手,只能通过在 wiring/ 里注册的 provider 接口拿。这是分层能长期活下去的机制成本。

十、诚实说重叠:工作流引擎那 6269 行

到这里为止我把自己说得很干净:我们只做运行时,不做框架。

那不完全对。

modules/workflow/ 是 6269 行,里面有编译器(compiler.py 259 行)、变量解析(variable_resolver.py 212 行)、节点执行器(executors/,其中工具节点 610 行)、恢复(resume.py)、回收(reaper.py)。这是一个编排引擎,它和市面上的编排类框架在功能上确实重叠。

我不打算辩解说「我们的不一样」。它就是重叠的。区别只有一条,还是第五节那条:它调工具走的是同一个 tool_port.invoke

所以更准确的说法不是「我们不做框架」,而是:我们在框架的地盘上只做了必要的那一部分,并且要求它跟别人守一样的规矩。

十一、「不是竞品」具体意味着什么——也说清楚现在还不能做什么

说三件能做的,和一件不能做的。

能做的:

  1. 框架侧的工具,通过 MCP 接进来被治理。 任何 MCP server 都能解析进工具注册表,之后它的每一次调用都要走第四节那十道关。
  2. 框架侧的服务,作为 HTTP 插件接进来。 adapters/plugins/http_runtime.pyskill_runtime.py 是插件运行时的两个实现(129 行 / 85 行)。
  3. 前端不用换。 交互走 AG-UI 事件流,进来的请求收的也是 AG-UI 的 RunAgentInput

不能做的(这一条比上面三条重要):

目前没有「把一个框架写的 agent 整个塞进来托管」的入口。你的循环仍然跑在你自己的进程里。SOIT 治理的是它伸出来的那只手——工具调用、模型调用、出网请求——而不是它脑子里的那段逻辑。

这是当前的真实边界,别按「万能容器」去预期它。如果你要的是「把现成的 agent 代码原样托管起来并获得全套治理」,今天这个仓库给不了你;能给的是「让那段代码伸出来的每一只手都必须报备」。

坦白局

  • 本篇是静态读代码加读配置的结论,没有做新的端到端实跑。 本文的每一条论断都能在3a57ae1 这个提交上打开文件核对,但「跑起来是不是真这样」不在本文的验证范围内。
  • 所有行数都是 wc -l 的结果,包含空行、注释和 docstring,不是「有效代码行」。它们用来做量级对比是合适的,用来做精确的工作量估算不合适。
  • README 那条不准确是真的(第六节),我们自己的文档在这件事上误导过读者。我打算提 issue 修,写稿时还没提。
  • 我没有对任何具体框架的内部实现做断言。 本文里「框架这一层负责什么」是行业共识层面的描述;凡是具体到行号的断言,全部来自我们自己的仓库。
  • SandboxToolPort 不是安全隔离。 kernel/ports/tools/sandbox.py 那 61 行是给发布前彩排用的 dry-run:让流程走完整条决策路径,但在边界上把副作用掐掉,免得排练一次就真开了一堆工单。它挡的是副作用,不是恶意代码,别当沙箱用。
  • 第五节那个论证有个前提:「换掉上面的执行模型、下面一道关不少」成立的条件是新的执行模型确实通过端口调用。绕过端口直接发 HTTP 请求的代码,这套治理管不着——第九节那条 import 契约挡的是内核被污染,不是业务代码绕路。

一句话结论

框架决定 agent 怎么想;运行时决定它伸手的那一下算不算数。

这两件事没有竞争关系,因为它们甚至不在同一个时间维度上——一个作用于你写代码的那几天,另一个作用于之后的每一次执行。

真要说有什么竞争,是注意力的竞争:在 agent 从 demo 走向生产的那个节点上,团队的注意力必须从前者转移到后者,而这个转移通常发生得太晚——晚到出了第一次事故之后。

来试试,也来挑刺

代码在 github.com/soit-ai/soit,Apache 2.0,本文所有行号对应提交 3a57ae1

最值得你打开的是这三个文件,它们是本文主张的全部依据:

  • server/app/modules/agent/runtime/executor.py(42 行,看它有多空)
  • server/app/kernel/ports/tools/policy.py(475 行,看那十道关)
  • server/importlinter.ini(看这条边界是怎么被焊死的)

如果你认为第五节那个论证有漏洞,或者你在别的项目里见过更好的分层办法,欢迎来 issue 里说。写这类文章最怕的就是自说自话。

利益相关:我是 SOIT 的维护者。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 先看结论
  • 一、这个问题为什么值得单独写一篇
  • 二、先数自家的 agent 循环:239 行
  • 三、那么,包着这 239 行的 1617 行在干什么
  • 四、分界线的确切位置:一次工具调用要过十道关
  • 五、一个能证明「治理不在循环里」的事实
  • 六、依赖清单是最诚实的定位声明
  • 七、入口收的是别人的协议,不是我们发明的形状
  • 八、模型这一层我们同样没写框架
  • 九、这条边界是被 CI 焊死的,不是文档里的口号
  • 十、诚实说重叠:工作流引擎那 6269 行
  • 十一、「不是竞品」具体意味着什么——也说清楚现在还不能做什么
  • 坦白局
  • 一句话结论
  • 来试试,也来挑刺
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档