2292 字
约 7 分钟
15
S21-X17 · 运行时接口分层:LLMEngine、LLMClient 与 LLMInterface 的职责边界
一个 agent 项目变乱的第一个征兆,是"改个重试逻辑要动三个文件",第二个是"换供应商要改业务代码"。根因几乎总是同一件事:把协议适配、调用治理、任务调度塞进了同一个类。这篇按三层切开——LLMInterface 管协议,LLMClient 管治理,LLMEngine 管调度——再谈进程内与进程外两种运行时,以及能力(skills / MCP)从哪来。
1. 一次调用穿过哪几层
| 层 | 职责 | 不该做的事 | 变更频率 |
|---|---|---|---|
LLMInterface |
消息/工具/流的协议适配 | 重试、限流、记账 | 换供应商时 |
LLMClient |
重试、超时、用量、流式拼装 | 业务分支、任务编排 | 调参时 |
LLMEngine |
队列、并发、批处理、生命周期 | HTTP 细节 | 扩缩容时 |
| Agent 层 | 规划、工具选择、终止判定 | 直接读供应商 SDK | 业务迭代时 |
分层的判据很朴素:改某一层时,另一层的测试不该变。做不到就说明边界画错了。
2. LLMInterface:最小契约
from typing import Protocol, Iterator, Sequence
class Message(dict): ... # {"role": ..., "content": ..., "tool_calls": [...]}
class LLMInterface(Protocol):
def complete(self, msgs: Sequence[Message], **kw) -> Message: ...
def stream(self, msgs: Sequence[Message], **kw) -> Iterator[str]: ...
def count_tokens(self, msgs: Sequence[Message]) -> int: ...
| 方法 | 为什么必须在契约里 | 常见缺项 |
|---|---|---|
complete |
非流式基线 | 少了它流式一失败就没退路 |
stream |
首字延迟与体验 | 缺了只能等全文 |
count_tokens |
预算与截断决策 | 缺了只能按字符估 |
tool_schema(可选) |
工具声明格式转换 | 各家 schema 字段名不同 |
count_tokens 被低估得最厉害。没有它,"超长输入"只能靠异常兜底,表现为偶发的 400;有了它,可以在拼上下文之前就做预算分配。
class OpenAIAdapter:
def __init__(self, client): self._c = client
def complete(self, msgs, **kw):
r = self._c.chat.completions.create(model=kw["model"], messages=list(msgs))
return Message(role="assistant", content=r.choices[0].message.content)
def stream(self, msgs, **kw):
for ch in self._c.chat.completions.create(..., stream=True):
if ch.choices[0].delta.content:
yield ch.choices[0].delta.content
3. LLMClient:治理逻辑只在这里长
import random, time
class LLMClient:
def __init__(self, impl, max_retry=4, base=0.8, budget_tokens=None):
self.impl, self.max_retry, self.base = impl, max_retry, base
self.used = 0
self.budget_tokens = budget_tokens
def call(self, msgs, **kw):
for i in range(self.max_retry):
try:
if self.budget_tokens and self.used >= self.budget_tokens:
raise BudgetExceeded(self.used)
r = self.impl.complete(msgs, **kw)
self.used += self.impl.count_tokens(msgs)
return r
except (RateLimit, Timeout) as e:
wait = self.base * (2 ** i) + random.uniform(0, 0.3) # 抖动
time.sleep(min(wait, 20))
raise RetryExhausted(msgs[-1].get("content", "")[:80])
| 治理项 | 放在哪 | 不放这里的后果 |
|---|---|---|
| 指数退避 + 抖动 | LLMClient |
同时重试造成二次雪崩 |
| 超时 | LLMClient |
长尾请求占满连接 |
| token 预算 | LLMClient |
单请求烧穿月度额度 |
| 降级模型 | LLMClient(或网关) |
供应商故障即业务中断 |
| 缓存 | 网关或 LLMClient |
重复请求重复付费 |
退避必须带抖动。固定间隔重试会把所有失败请求对齐到同一时刻,形成周期性尖峰——这是"重试把故障放大"的标准剧本。
4. LLMEngine:并发与批处理的边界
import asyncio
class LLMEngine:
def __init__(self, client, max_concurrency=8):
self.client = client
self.sem = asyncio.Semaphore(max_concurrency)
async def submit(self, msgs, **kw):
async with self.sem: # 并发闸门在这一层
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: self.client.call(msgs, **kw))
async def batch(self, items, **kw):
return await asyncio.gather(*[self.submit(m, **kw) for m in items],
return_exceptions=True)
| 关注点 | 归属 | 说明 |
|---|---|---|
| 并发上限 | LLMEngine |
与供应商配额对齐,不是越大越好 |
| 排队与优先级 | LLMEngine |
交互请求优先于批处理 |
| 取消与超时传播 | LLMEngine |
用户断开要能取消下游调用 |
| 结果聚合 | LLMEngine |
部分失败要能返回成功部分 |
| 重试 | LLMClient |
引擎只负责不重复重试 |
return_exceptions=True 是必须的:批处理里有 1 条失败就整体抛异常,等于让一条坏数据毁掉整批。
5. 进程内 vs 进程外:CLIAgent 与 Google-ADK 的分野
| 维度 | 进程内(SDK 直嵌) | 进程外(CLIAgent 式命令行代理) |
|---|---|---|
| 部署 | 与应用同一生命周期 | 独立进程,可单独升级 |
| 故障隔离 | 崩溃即拖垮宿主 | 崩溃只影响子进程 |
| 能力边界 | 受宿主权限约束 | 受沙箱与子进程权限约束 |
| 观测 | 共享日志与 trace | 需通过标准输入输出或事件流采集 |
| 典型框架 | Google-ADK 这类库式框架 | CLI 型代理、容器化执行器 |
判据:需要与业务事务同生共死就进程内,需要隔离与独立伸缩就进程外。把 CLI 型代理嵌进 Web 请求线程里,是最常见的"看起来跑通、一上量就崩"的写法。
6. Agent-Skills 与 MCPClient:能力从哪来
| 来源 | 形态 | 加载方式 | 适合 |
|---|---|---|---|
| 内置工具 | 函数 + schema | 代码里注册 | 稳定、少量 |
| Agent-Skills | 说明文档 + 资源 + 脚本 | 按需加载到上下文 | 领域流程、可复用方法论 |
| MCPClient | 外部服务的能力清单 | 连接时发现(list tools) | 跨系统、多人维护 |
class MCPClient:
def __init__(self, transport): self.t = transport; self.tools = {}
def discover(self):
self.tools = {t["name"]: t for t in self.t.call("tools/list")["tools"]}
return list(self.tools)
def invoke(self, name, args):
if name not in self.tools:
raise UnknownTool(name) # 名字漂移要显式失败
return self.t.call("tools/call", {"name": name, "arguments": args})
按需加载 Skill 而不是全量注入,是控制上下文的关键:一个 20 个 skill 的项目,全量注入能吃满半个窗口。
7. UIToolCall:把"要人确认"的动作显式化
| 维度 | ToolCall | UIToolCall |
|---|---|---|
| 执行位置 | 服务端 | 前端(浏览器/客户端) |
| 副作用可见性 | 模型侧不可见 | 用户可见、可取消 |
| 回执 | 工具返回值 | 用户操作结果 + 前端状态 |
| 适合 | 查询、计算、内部写 | 表单填写、跳转、二次确认 |
| 风险 | 越权调用 | 前端伪造回执 |
def dispatch(call):
if call.name.startswith("ui."):
return {"type": "ui_action", "action": call.name[3:],
"args": call.args, "require_confirm": call.args.get("destructive", False)}
return {"type": "tool", "result": tools[call.name](**call.args)}
关键点:UI 类动作的回执是不可信输入。前端说"用户已确认",服务端仍需按自己的权限模型校验一次。
8. E2BBackend:把执行放到远端沙箱
| 方案 | 隔离级别 | 启动延迟(典型量级) | 适合 |
|---|---|---|---|
| 本地 subprocess | 弱,共享文件系统 | 毫秒级 | 可信代码、开发期 |
| 容器 | 中,独立 namespace | 百毫秒到秒级 | 内部工具 |
| 远端沙箱(E2BBackend 这类) | 强,独立 VM/运行时 | 秒级 | 不可信代码、多租户 |
class E2BBackend:
def __init__(self, sandbox): self.sb = sandbox
def run(self, code, timeout=30):
return self.sb.run_code(code, timeout=timeout,
envs={"PYTHONUNBUFFERED": "1"})
选它的判据不是"更安全",而是你的代码来源是否可信 + 是否需要多租户隔离。可信代码上远端沙箱只是把延迟从毫秒推到秒。
9. 排错:六种接口层现场
| 现象 | 原因 | 处理 |
|---|---|---|
| 换供应商后工具调用全失效 | 适配器没转 tool_schema 字段名 |
在 LLMInterface 层做 schema 映射 |
| 高峰期出现周期性 429 尖峰 | 退避无抖动,重试对齐 | 加 random.uniform 抖动 |
| 批处理一条失败整批失败 | gather 未加 return_exceptions |
改为返回成功部分 + 失败清单 |
| 流式偶发丢字 | 分片边界处理错,按 chunk 解码 | 按字节缓冲,不按字符切 |
| 上下文莫名超窗 | 没有 count_tokens,靠字符估 |
实现并纳入预算 |
| UI 动作被重复执行 | 回执无幂等键 | 每次 UI 动作带 request_id |
10. 边界:这些情况不需要三层
| 场景 | 做法 | 理由 |
|---|---|---|
| 单供应商、单脚本 | 直接调 SDK | 三层是纯开销 |
| 原型验证阶段 | 一个薄封装 | 过早抽象会锁死试错 |
| 只有离线批处理 | SDK + 简单重试 | 无并发治理需求 |
| 供应商已提供官方网关 | 网关 + 薄适配 | 别重复实现限流与记账 |
11. 自测
LLMInterface里为什么必须有count_tokens?缺了它会以什么形式暴露?- 指数退避为什么必须带抖动?不带会出现什么周期性现象?
- 并发闸门为什么放在
LLMEngine而不是LLMClient? - 进程内 SDK 与 CLIAgent 式进程外代理,各自适合什么部署约束?
- Agent-Skills 按需加载解决的是哪一类预算问题?全量注入会怎样?
- UIToolCall 的回执为什么必须当不可信输入处理?如何在服务端兜住?
S21-X17 · 运行时接口分层:LLMEngine、LLMClient 与 LLMInterface 的职责边界
https://www.clxhxhhr.top/posts/6013/ S21-X16 · 智能体拓扑与交接:Single-Agent、Orchestrator-Workers 与 Parent-Child 上下文传递
S22-X15 · LLMOps 迭代闭环:成本账、灰度与用例门禁
评论
0 条
还没有评论,先写一条吧。