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. 自测

  1. LLMInterface 里为什么必须有 count_tokens?缺了它会以什么形式暴露?
  2. 指数退避为什么必须带抖动?不带会出现什么周期性现象?
  3. 并发闸门为什么放在 LLMEngine 而不是 LLMClient?
  4. 进程内 SDK 与 CLIAgent 式进程外代理,各自适合什么部署约束?
  5. Agent-Skills 按需加载解决的是哪一类预算问题?全量注入会怎样?
  6. UIToolCall 的回执为什么必须当不可信输入处理?如何在服务端兜住?
S21-X17 · 运行时接口分层:LLMEngine、LLMClient 与 LLMInterface 的职责边界
https://www.clxhxhhr.top/posts/6013/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。