判断一个东西是框架还是运行时,我后来用的是这么一条土办法:看它在你不写代码的时候还管不管事。
框架管的是你写代码的那段时间——怎么描述一个 agent、怎么串工具、怎么组织提示词。运行时管的是代码写完之后的每一次执行——这一下允不允许、记不记得住、超时了算谁的、出了事能不能回放。
这条界线听起来很虚,但它在代码里是有具体位置的。本文就是把这个位置指出来:在我们的仓库里,它落在 ToolPolicyGateway.invoke() 这个方法上,一次工具调用要在这里过十道关。
如果只看一段,看这张表:
Agent 框架 | Agent 运行时 | |
|---|---|---|
管的时间 | 你写代码的时候 | 代码写完之后的每一次执行 |
核心问题 | agent 怎么想、怎么组织、怎么调用 | 这一下允不允许、记不记得住、算谁的账 |
典型能力 | 提示词组织、链/图编排、模型与工具的封装、开发期体验 | 权限判定、密钥边界、出网策略、台账与重放、成本归属、审批中断 |
衡量它好不好的标准 | 表达能力、上手速度、生态里有多少现成件 | 出事之后能不能查清、能不能拦住、能不能重放 |
在我们仓库里对应哪一块 |
|
|
体量 | 239 行 | 光一个工具端口的策略网关就 475 行 |
这篇文章要论证的就是这张表的最后两行——它不是我画出来的示意图,是 wc -l 数出来的。
「Agent 平台」这个词现在至少吃掉了三层东西:写 agent 的开发库、跑 agent 的执行引擎、管 agent 的治理面。三层混在一个词里,于是每次讨论都变成鸡同鸭讲——一方在说「我用它三行就搭出一个 agent」,另一方在说「我要知道昨天下午三点那次调用是谁批的」。两句话都对,但根本不在回答同一个问题。
我不打算去评判任何一个具体框架的内部实现——那需要我读透别人的代码才有资格说,而这篇文章里的每一条我都要能给出行号。所以本文只做两件事:
样本是 github.com/soit-ai/soit,Apache 2.0,本文所有行号对应提交 3a57ae1。
一个 agent 的核心循环,抽象出来无非三件事:想下一步(plan)、做这一步(execute)、看看做完了没(verify)。在我们仓库里这三件事各占一个文件:
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 行几乎可以整个贴出来:
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,要么是一段文本,没有第三种情况:
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,两个字段 ok 和 reason)问模型「这答复够不够」,仅此而已。
这一层是框架的主场,而我们在这一层刻意做得很薄。 薄不是优点也不是缺点,它只是说明:我们没打算在这里跟谁比表达能力。
server/app/modules/agent/application/service.py 是 1617 行。它的 import 列表其实已经把答案说完了:
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 写入器、审批规则、工具解析器。再加上文件里那个专门用来做人工审批中断的内部异常:
class _AgentApprovalInterrupt(Exception):
"""Internal control signal for a durable human approval checkpoint."""而真正的循环只有一行 while(service.py:653):
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 引用就去 |
|
2 | 台账认领 | 在运行台账里认领这次调用,生成或复用 |
|
3 | 幂等重放 | 认领结果标记为 |
|
4 | 租约 | 给这次执行挂一个租约( |
|
5 | 出网策略 | 把参数里所有 http URL 抠出来,逐个过 |
|
6 | 速率限制 | 按 |
|
7 | 日配额 | 按 |
|
8 | 链路追踪 | 开一个 |
|
9 | 超时与重试 | 统一的超时重试;带幂等键时 |
|
10 | 审计与结账 | 写审计日志(记的是脱敏后的参数)、更新步骤状态与指标、记成本 |
|
注意第 1 关和第 10 关的配合:真值只进入调用,脱敏副本才进入记录。代码里为此专门维护了 resolved_parameters 和 redacted_parameters 两份数据,审计与指标一律用后者。这类事情很难在写业务代码时靠自觉做到,只能靠它站在必经之路上。
第 9 关的注释也很说明问题:
# 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 循环的执行模型完全不同。
那么它调工具走的是什么路?
# 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 相关的只有三个:
"mcp>=1.28.1,<2", # 工具协议
"ag-ui-protocol==0.1.19", # 前端交互事件协议
"litellm==1.91.1", # 模型调用三个全是协议件或调用层,没有任何一个 agent 框架在核心依赖里。这不是宣言,这是 pyproject.toml 第 68 到 71 行。
那 LangChain 呢?在。但在这儿:
[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:
# 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.py 与 responses.py,共 909 行)。
三个接缝都是协议,不是我们的方言。这是「两层能拼在一起」的前提条件。
adapters/llm/ 一共 2619 行,其中 router.py 534 行。这 534 行做的是路由、凭据解析和出网守卫,不是提示词组织:
# server/app/adapters/llm/router.py:192(在 _authorize_provider_target 内)
await self.egress_guard.authorize(ctx, f"model-provider:{provider_slug}", url)解析模型提供方的两条路径(router.py:368 与 router.py:422)都要先过这一步。也就是说,连调模型这件事本身都要过出网策略——目标域名不在策略里,这次调用就出不去。
再看一条容易被忽略的:生产环境下,没有配凭据的模型提供方会被直接拒绝(router.py:316–324,错误码 MODEL_PROVIDER_CREDENTIAL_REQUIRED)。这类「生产模式拒绝你偷懒」的判断也是运行时层的典型职责——它不改善你的开发体验,它只是不让你把开发期的将就带到生产。
分层最常见的下场是写在文档里、烂在代码里。所以这一层我们交给了工具。
server/importlinter.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.wiringkernel 不许 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 接口拿。这是分层能长期活下去的机制成本。
到这里为止我把自己说得很干净:我们只做运行时,不做框架。
那不完全对。
modules/workflow/ 是 6269 行,里面有编译器(compiler.py 259 行)、变量解析(variable_resolver.py 212 行)、节点执行器(executors/,其中工具节点 610 行)、恢复(resume.py)、回收(reaper.py)。这是一个编排引擎,它和市面上的编排类框架在功能上确实重叠。
我不打算辩解说「我们的不一样」。它就是重叠的。区别只有一条,还是第五节那条:它调工具走的是同一个 tool_port.invoke。
所以更准确的说法不是「我们不做框架」,而是:我们在框架的地盘上只做了必要的那一部分,并且要求它跟别人守一样的规矩。
说三件能做的,和一件不能做的。
能做的:
adapters/plugins/http_runtime.py与 skill_runtime.py 是插件运行时的两个实现(129 行 / 85 行)。RunAgentInput。不能做的(这一条比上面三条重要):
目前没有「把一个框架写的 agent 整个塞进来托管」的入口。你的循环仍然跑在你自己的进程里。SOIT 治理的是它伸出来的那只手——工具调用、模型调用、出网请求——而不是它脑子里的那段逻辑。
这是当前的真实边界,别按「万能容器」去预期它。如果你要的是「把现成的 agent 代码原样托管起来并获得全套治理」,今天这个仓库给不了你;能给的是「让那段代码伸出来的每一只手都必须报备」。
3a57ae1 这个提交上打开文件核对,但「跑起来是不是真这样」不在本文的验证范围内。wc -l 的结果,包含空行、注释和 docstring,不是「有效代码行」。它们用来做量级对比是合适的,用来做精确的工作量估算不合适。SandboxToolPort 不是安全隔离。 kernel/ports/tools/sandbox.py 那 61 行是给发布前彩排用的 dry-run:让流程走完整条决策路径,但在边界上把副作用掐掉,免得排练一次就真开了一堆工单。它挡的是副作用,不是恶意代码,别当沙箱用。框架决定 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 删除。