aps-agent/docs/architecture.md

25 KiB
Raw Blame History

多业务系统智能体平台 · 架构设计

来源:飞书文档(原文,A0-G 评审快照,2026-09-23 导入) 权威版本:本仓库。导入后飞书副本停止更新,改动走仓库提交与 ADR 流程。

**一句话:**用户在前台说需求,Pi 这个大脑把它翻译成工具调用,Core 门禁决定能不能做,Provider 翻译成各系统自己的接口,最后由业务系统执行。

本文覆盖四件事:客户端与服务端的边界、项目代码结构、控制面与智能体运行时的实现、Provider 的接入方式。文中的目录与能力名称为设计示例。

**与现有项目的关系:**本次是对现有 APS 项目的重构。现有代码不保留,只作借鉴;新系统从零实现。项目仍在开发阶段,没有客户试用数据,因此不考虑与既有数据、表结构、接口版本的兼容与迁移——旧的一切随旧代码一起退役。

落地细节(Provider 套件怎么装、多 Sub-agent 怎么协作、数据与界面、文档规范与测试)见配套文档:多系统智能体平台 · 实施设计

1 客户端与服务端

flowchart TB
  subgraph CLIENT[客户端]
    DESK["Electron 桌面端"]
    WEBUI["Web 浏览器"]
  end

  subgraph SERVER["Agent Platform Server"]
    ENTRY["HTTP / SSE 入口"]
    APP["应用服务
会话 · 任务 · 事件发布
平台持有会话与业务记录"]
    ENGINE["统一 AgentEngine 契约
开发 / 部署启动时注入一个实现"]

    subgraph RUNTIME["引擎实现二选一 · 每个部署实例只运行一种"]
      PIAD["PiAdapter"]
      CDAD["CodexAdapter"]
      PI["Pi agent-core
原生状态由适配器管理"]
      CODEX["Codex app-server
原生状态由适配器管理"]
      PIAD --> PI
      CDAD --> CODEX
    end

    CORE["平台 Core
可信身份 · 代码策略 · 持久审批
幂等执行 · 审计"]
    SUITE["Provider 接入
契约 · 路由 · 适配"]
  end

  S1["APS"]
  S2["ERP"]
  S3["EMS"]

  DESK --> ENTRY
  WEBUI --> ENTRY
  ENTRY --> APP
  APP --> ENGINE
  ENGINE --> PIAD
  ENGINE --> CDAD
  PI -->|工具桥接| CORE
  CODEX -->|工具桥接| CORE
  APP -->|能力查询 · 审批决策 · 执行状态| CORE
  CORE --> SUITE
  SUITE --> S1
  SUITE --> S2
  SUITE --> S3

  classDef default fill:#ffffff,stroke:#8a96a3,stroke-width:1.2px,color:#243247;
  classDef selected fill:#fff3e8,stroke:#d78842,stroke-width:1.5px,color:#563418;
  classDef ext fill:#fafafa,stroke:#8a96a3,color:#243247,stroke-dasharray:5 4;
  class ENGINE selected;
  class S1,S2,S3 ext;
  style CLIENT fill:#f8fafc,stroke:#b7c0c9,color:#243247
  style SERVER fill:#f8fafc,stroke:#b7c0c9,color:#243247
  style RUNTIME fill:#ffffff,stroke:#b7c0c9,stroke-dasharray:5 4,color:#526174
  linkStyle default stroke:#8a96a3,stroke-width:1.3px;

整体分两段:客户端只做界面与本地能力;服务端是引擎中立的平台服务,内部再分入口与应用服务、引擎适配、控制面、适配器四块。Pi 与 Codex 二选一,在开发或部署期选定,整段链路对客户端透明。APS、ERP、EMS 始终是平台外部的独立系统。

段 / 层 打个比方 负责 不负责
客户端 前台 界面、工作区、本地能力(文件、打印、设备)、断网缓存 业务判断、权限判断、持有业务凭据
应用服务 传达室 + 调度员 HTTP / SSE、会话与任务、能力查询、审批流转、事件推送 业务规则、直接调业务系统
Agent 引擎 大脑 理解目标、挑工具、把结果讲成人话;Pi 或 Codex 二选一 碰凭据、判断权限、直连业务库
平台 Core 门禁 + 调度台 决定能用哪些能力、以谁的身份、要不要审批,并留痕 业务规则、业务数据
Provider 接入 翻译官 把统一调用翻译成某个系统的 API,再翻译回统一回执 业务决策、持有模型上下文
业务系统 车间 自己的数据、权限、规则和状态,最终执行 因为平台放行就执行

2 项目结构

一个 monorepo,按客户端与服务端两段组织。服务端是引擎中立的平台服务:应用服务、引擎适配、控制面、Provider 接口各归其位,引擎在适配层内部选定;客户端只通过一套接口与其通信。业务系统的代码不在这个仓库里。

agent-platform/
├── client/                      # 客户端:桌面与浏览器共用
│   ├── app/                     # React 界面:对话、工作区、审批
│   └── desktop/                 # Electron 壳:窗口、本地守护、离线升级
├── server/                      # 服务端:引擎中立的平台服务
│   ├── entry/                   # HTTP / SSE 入口、会话、鉴权
│   ├── app/                     # 应用服务:请求编排、能力查询、审批流转、事件推送
│   ├── agent/                   # 引擎适配:统一接口 + Pi / Codex 两个实现
│   ├── core/                    # 控制面
│   │   ├── registry/            # 能力目录、实例与路由
│   │   ├── identity/            # 平台用户 ↔ 各系统账号
│   │   ├── policy/              # 允许 / 拒绝 / 需审批
│   │   ├── credentials/         # 凭据托管与取用
│   │   ├── execution/           # 授权、幂等、调用网关
│   │   └── audit/               # 证据链
│   ├── provider-sdk/            # Provider 契约与契约测试
│   └── workflows/               # 跨系统顺序流程
├── providers/                   # aps / erp / ems,只放适配器
├── contracts/                   # 客户端与服务端共享的接口类型
├── deploy/                      # 部署与实例配置
└── docs/                        # 架构与接入文档
目录 职责 不该出现什么
client/app 对话、工作区、审批界面(React) 业务判断、直接调 Provider
client/desktop Electron 壳:窗口、本地守护、离线升级 业务逻辑、绕开服务端直连 Provider
server/entry HTTP / SSE 入口、会话与鉴权、装配各模块 业务规则、直接调业务系统
server/agent 引擎适配层:统一接口,以及 Pi / Codex 两个实现;唯一允许 import 引擎 SDK 的地方 业务逻辑、直接 HTTP 调业务系统
server/core 能力目录、身份、策略、凭据、执行、审计 import 任何具体 Provider
server/provider-sdk Provider 接口、Schema、契约测试、跨进程调用 具体系统的业务语义
server/workflows 跨系统顺序流程定义 协议与字段细节
providers/aps APS 适配器:manifest、adapter、字段映射 排产算法、业务数据
contracts 客户端与服务端共享的类型与接口定义 任何运行时实现细节

**依赖方向:**client → server/entry → server/app → server/agent → server/core → provider-sdk ← providers。反向依赖用 dependency-cruiser 或 eslint 的 no-restricted-imports 卡在 CI 里,避免几个月后又缠成一团。

3 一次调用怎么走

用户说:“把下周的排产计划往后推一天。”

sequenceDiagram
    participant C as 客户端
    participant A as 应用服务
    participant E as Agent
    participant K as 平台 Core
    participant P as Provider
    participant B as 业务系统
    Note over A,E: Agent 通过统一接口接入,Pi / Codex 在开发或部署期选定
    C->>A: 消息与身份令牌
    A->>A: API 鉴权、会话管理
    A->>K: 按身份与会话请求可用工具
    K-->>A: 工具清单与授权作用域
    A->>E: 消息、上下文与受限工具集
    E->>K: 受限工具桥:结构化调用
    K->>K: 校验身份、作用域与策略
    Note over E,K: 业务审批由 Core 管理,与引擎命令权限分开
    opt 策略要求业务审批(通过后继续)
        K->>K: 持久化审批单 approvalId
        K-->>A: 待审批事件与影响范围
        A-->>C: 业务审批卡:参数与资源版本
        C->>A: 审批决定与身份凭据
        A->>K: approvalId、决定与审批人
        K->>K: 校验审批人及授权 grant
    end
    K->>K: 复核权限与资源版本
    K->>K: 持久化 executionId 与幂等键
    K->>P: 受控执行与短期凭据
    P->>B: 业务 API 调用
    B->>B: 校验自身业务规则
    B-->>P: 执行回执
    P-->>K: 结构化回执
    K->>K: 持久化执行结果与审计
    K-->>E: 工具结果与证据引用
    E-->>A: 统一格式的 Agent 事件
    A-->>C: 流式呈现结果与状态
边界 传什么 谁决定
客户端 → 服务端 消息与身份令牌;不含业务凭据 客户端发起,服务端受理
应用服务 → 引擎 用户目标、上下文、本会话可见的能力清单 应用服务装配,引擎不决定权限
引擎 → Core capabilityId 与参数;不含凭据,不自报身份 引擎提议,Core 裁决
Core → Provider 主体、实例、连接、executionId、deadline、参数、幂等键、授权引用 Core
Provider → 业务系统 原生 API 调用 Provider
Provider → Core 状态、结果引用、jobId 或 errorKind 以业务系统的事实为准
Core → 客户端 结果与证据引用,经 SSE 流式下发,不回灌凭据 Core

记住一句就够:引擎只能提议,Core 决定能不能做,业务系统最终说了算。所以换引擎或换模型都不影响安全,加系统也不用动引擎。

4 控制面 Core 怎么实现

Core 不是所有请求的中转站,而是一组被多方调用的确定性后端模块:应用服务在运行前查能力、在审批时落决定、在事后查执行;Agent 在运行中提出工具调用。这些调用都是普通代码调用,不需要再放几个 Agent 来判断。

五个服务,首版放在同一个进程里,共用 PostgreSQL:

服务 具体做什么 正确性的关键
身份 Identity 验证登录身份,查用户所属租户、项目角色,以及对应业务系统的账号映射 身份来自可信登录链路;不接受模型自报的 userId
策略 Policy 用代码判断角色、能力、目标资源与操作类型,返回允许 / 拒绝 / 需审批 默认拒绝;每次真正执行前都检查,不能只在展示工具列表时过滤
审批 Approval 把待确认动作与状态落库,界面提交确认或拒绝,校验确认者是会话发起人 确认绑定具体动作、参数、目标与版本;不能变成通用通行证
执行 Execution 建执行记录,管幂等、并发、超时,调用 Provider,跟踪异步任务 防重复执行;超时不等于失败,结果未知时先查询
审计 Audit 记录请求人、批准人、操作、参数摘要、结果与证据引用 记录完整且关联一致,并限制修改权限;普通日志文件不够

**以「发布排产计划」为例:**Agent 提出「发布计划 P,版本 V」→ Core 确认身份 → 判断策略 → 需要则建审批单并推送确认卡 → 批准后复核权限与资源版本 → 用唯一执行编号与幂等键调 Provider → 保存回执与审计记录。成功记回执,异步受理跟踪 jobId,结果未知进待核实。

三个容易被低估的地方:

常见误解 实际情况
审批就是一个弹窗 弹窗只是界面。真正的审批是数据库里的状态与授权记录,进程重启、Agent 退出后依然存在;聊天里说一句「同意」不能批准任意操作
执行就是发请求,失败就重试 对方可能已经执行成功而回执丢失。只有业务系统支持可靠幂等或状态查询时才能安全重试,否则必须保留「结果未知」
Core 放行就代表能执行 Core 查的是平台权限与授权条件;业务系统仍要校验自己的规则。审批后计划被改,应由版本校验阻止发布旧方案

**为什么要这么做:**业务操作是否被允许、是否已执行、是否需要审批,由这套代码决定,不随所选引擎变化。前提是每个引擎的业务调用都必须经过 Core,不能存在绕开的路径。

4.1 协作与权限以业务系统为准

平台不做协作模型:没有审批转派、委托、多人计数、角色策略、双人分离。这些是业务系统(APS、ERP)本来就有的能力,平台重复实现一遍必然和它们的权限模型对不上。人的协作发生在业务系统里,平台只负责把人映射进去、把动作调出去、把过程记下来。

职责 谁负责 说明
能否做(权限、角色、多级审批) 业务系统 用该用户在业务系统里的身份判定;平台不复制这套规则
用什么身份做(账号映射) 平台 把登录用户映射到各系统账号,调用时使用用户自己的身份,业务系统的权限随之生效
是不是想这么做(意图确认) 平台 确认卡防的是 Agent 误解意图,不是防越权;答的只是发起人本人
做过什么(审计与执行记录) 平台 业务系统只知道账号改了数据,不知道经由 Agent、模型建议了什么、用户确认了什么

**一句话:**权限归业务系统,映射与确认归平台。动作在业务系统里已有审批流的,交给它审;只有跨系统编排这类没人能替你决定意图的动作,才由平台出确认卡。

4.2 确认与执行记录只存一张表

现有实现用三张表(审批请求、一次性凭据、审批事件)承载一件事。单用户场景下可以合并成一张待确认动作表:请求参数摘要、状态(待确认 / 已确认 / 已执行 / 已拒绝)、执行回执、幂等键、有效期。

**为什么能合:**批准与执行由同一个人、同一个进程完成,不需要把"批准"与"执行"拆成两个可独立消费的凭据;审批事件与审计记录重复,只保留审计。**不能省的两件事:**待确认状态必须跨进程重启存活(用户可能过一会儿才回来点确认),执行只能生效一次(幂等键加执行记录)。

5 引擎适配

5.1 适配层的边界规则

Pi 以 npm 包形式发布(MIT),装进 monorepo 当依赖,不需要 git submodule。首版把 Pi 作为长驻子进程运行:用官方 RpcClient 驱动(它负责拉起进程、按 id 关联响应、把事件推给监听者),协议是标准输入输出的 JSONL。这样与 Codex 的进程模型一致,也把智能体循环从平台的事件循环里隔离出去。

**硬前提:**Node ≥ 22.19.0,部署镜像要跟上。具体接入方式、代码示例与推进顺序见实施设计。

边界规则 说明
只有一个入口 只有 server/agent 允许 import Pi,其它模块必须通过这一层
工具只来自能力目录 不注册文件、shell、网络类工具,避免绕开 Core
每次都过 Core 工具的 execute 内部只调 core.invoke,不允许自己发 HTTP 请求到业务系统
按身份给工具 工具列表按用户身份过滤;切换用户要重建会话,不能复用工具集
凭据不进工具 凭据在 server/core/execution 注入,工具参数与模型上下文里出现凭据即视为缺陷
会话落库 消息与执行记录存 PostgreSQL;Pi 只负责内存态与事件流

5.2 两个引擎与切换方式

两个引擎都以长驻子进程运行,循环模型一致:平台拉起进程、发提示、收事件、发取消,互不理解对方的内部协议。差异集中在工具面——Codex 可通过 MCP 或实验性动态工具接收平台能力,Pi 需要在 pi 侧写一个扩展,把工具调用以本地 HTTP 回调平台,平台再走 Core.invoke。所以适配层要抹平的是工具面,不是事件循环。

目标是可替换,不是行为一致:三个固定点不动,两个实现可换。

**切换发生在开发或部署期,不是用户使用中。**一个部署实例只运行一种引擎,整段链路对客户端透明;这省掉了跨引擎会话交接、双引擎并行和审批跨引擎延续等一大堆复杂度。同一会话不中途换引擎。

固定点 内容 为什么
领域类型 会话、消息、事件、回执都是我们自己的类型 一旦运行时的类型渗出来,换的时候就是全平台改造
工具出口 任何运行时的工具调用都落到 Core.invoke 这是门禁,也是唯一能统一两个运行时的地方
状态 会话与执行记录由平台保存 运行时只借循环、不借状态,换的时候不用迁数据

接口只描述「怎么驱动一个循环」,不描述「允许做什么」:

interface AgentRuntime {
  createSession(input: {
    subject: Subject;
    system?: string;
    tools: ToolDescriptor[];   // 能力目录投影
    history?: Message[];       // 平台保存的历史,可恢复
  }): Promise<RuntimeSession>;
}

interface RuntimeSession {
  send(text: string, opts?: { signal?: AbortSignal }): AsyncIterable<RuntimeEvent>;
  abort(): void;
}

type RuntimeEvent =
  | { type: "text"; delta: string }
  | { type: "tool_call"; callId: string; name: string; args: unknown }
  | { type: "tool_result"; callId: string; receipt: ExecutionReceipt }
  | { type: "approval_required"; request: ApprovalRequest }
  | { type: "turn_end"; usage: Usage }
  | { type: "error"; kind: ErrorKind };
部件 Pi Codex 共同契约
驱动 进程内库 子进程 app-server AgentRuntime 接口
工具面 进程内注册工具 MCP 服务,并关掉它自带的 shell 与文件工具 两边都落到 Core.invoke
事件 subscribe 回调 协议事件流 统一的 RuntimeEvent
审批 不内置,由 Core 出确认卡 自带审批,需映射到 Core 确认卡 统一的审批请求与响应
会话 平台存 平台存,镜像它的事件 平台自己的会话格式

**难点只有一个,而且是已知的:**工具面的接法两边不同——Codex 有现成的 MCP 通道,Pi 要靠扩展桥。所以「兼容」的实质是两个工具面都要实现,且都必须汇到 Core.invoke。这也是为什么切换成本集中在工具面,而不是事件循环。进程管理那部分反而是共用的:启停、超时、崩溃重启、日志轮转、父进程看门狗只写一套。

**不实现别人的协议。**Codex 的协议只活在它的适配器内部,客户端与 Core 都不感知;服务端与客户端之间只认我们自己的一套接口(HTTP / SSE)。协议的所有权比协议的兼容性重要。

**推进方式:**现在只做四件事——定义引擎接口、守住三条纪律、写引擎进程管理与 Pi 扩展桥、写一个假引擎跑一致性测试;Codex 驱动等到真要切换时再写。

**验收方式就是一套一致性测试:**两个驱动跑同一套用例——多轮对话、工具调用与回执、事件顺序、取消、错误分类、会话恢复。过不了就不算兼容。

**做不到的部分要说清楚:**运行时独有的能力(Pi 的插话与追问、Codex 的沙箱)用能力声明暴露,界面按开关走,不做统一封装;审批粒度不可能完全对等,Codex 审批命令与补丁,我们审批业务动作,只能近似映射;提示词与工具描述格式不同,要各调一份。

6 Provider 契约

Provider 是能力适配器,不是业务系统,也不承载模型上下文。接口只三个方法:

interface Provider {
  describe(): Promise<ProviderManifest>;   // 声明能力
  invoke(ctx: ExecutionContext, call: CapabilityCall): Promise<ExecutionReceipt>;
  health(): Promise<HealthStatus>;
}

interface Capability {
  id: string;                          // 如 aps.schedule.publish
  description: string;                 // 给模型看的用途说明
  effect: "read" | "draft" | "write";  // draft:只写隔离草稿
  mode: "sync" | "async";
  inputSchema: JsonSchema;
  outputSchema: JsonSchema;
  idempotency: "none" | "key" | "natural";
  errors: ErrorKind[];
}

Provider 的开发步骤、运行机制与契约测试见实施设计。

**能力声明的取舍:**read 不改变状态;draft 只写隔离草稿,不走审批但必须声明清理方式;write 必须绑定唯一目标、幂等键与授权引用。sync 直接返回结果,async 先返回 jobId。

7 桌面端怎么选

桌面端在客户端这一侧,只做界面与本地能力;引擎与 Core 都在服务端。服务端可以集中部署在内网、与客户端同机部署,或混合部署(本地只保留设备与文件能力)。桌面壳的选择不影响智能体架构,真正决定选型的是“服务端部署在哪、工厂有没有网”。

方案 体积 Node 支持 工厂离线环境 结论
Electron 150MB 起 内置,Pi 可直接跑 自带 Chromium,不依赖系统组件 首版推荐
Tauri v2 10-20MB,另加 Node sidecar 需自带 sidecar 二进制 依赖系统 WebView2,老机器可能缺失 包体或内存成为硬指标时再评估
纯浏览器 / PWA 0 无 依赖现场浏览器版本 不需要本地设备能力时最省

**首版选 Electron:**工厂离线 PC 不用赌 WebView2,离线升级、进程守护、单实例都有成熟做法。Tauri 包体小,但没有 Node 运行时,主进程逻辑要用 Rust 重写且依赖系统 WebView2;等包体或内存成为硬指标时再评估。

桌面端边界 说明
不直连 Provider 所有业务调用走平台 API,桌面端只是入口
不持有业务凭据 凭据只存在平台凭据代理,本地不落长期密钥;设备证书加用户登录双重身份,换设备或换人都可追溯
本地 runner 只做本地事 打印、扫码、文件、设备采集可以本地做,业务写操作仍要过 Core
离线只缓存不越权 断网时排队与重试,恢复后仍要过策略与审批,不能本地放行

8 首版范围

先做 暂不做
一个 TypeScript / Node 平台服务 + Pi 运行库 + PostgreSQL OPA、消息总线、Temporal / Camunda
Electron 桌面壳,复用同一套 Web 界面 Tauri 迁移
Provider 跑在平台侧,进程内或边车 可视化编排
代码内声明的顺序流程 + 每步结果落库 配置化 DAG 引擎
APS 三个能力跑通,外加 ERP 或 EMS 一个只读能力 一次接入全部系统
验收项 标准
身份 不同系统的登录态可独立失效;桌面端能定位到设备与用户
权限 业务拒绝不会被 Core 放行
幂等 同一写请求重试不重复生效,或明确返回结果未知
停用 停用 Provider 后不再接收新调用
可观测 一个系统失败时能看清各步骤的实际结果

9 附录:ChatGPT 客户端的架构

记录另一个真实产品的做法,供参考,不是本项目采用的方案。一句话概括:宿主负责策略与界面,独立进程负责循环与执行,能力以 MCP 子进程接入,审批用 RPC 回流。证据来自本机进程实测与开源仓库源码,官方文档站在当前环境访问不到。

部件 形态 职责
外壳 ChatGPT.exe(Electron),9 个进程 窗口与交互,以及宿主能力:文件、浏览器、电脑操作、插件
agent 后端 codex app-server(Rust),1 个进程 模型调用、智能体循环、工具编排、沙箱执行、审批策略
能力进程 MCP 服务器(Node),22 个进程 每个启用的插件一个,作为工具接入后端
状态 ~/.codex 会话与回放记录、会话索引、历史文件,以及线程历史、状态、日志等数据库
通信 标准输入输出;源码还支持本地 socket 与 WebSocket 自有 app-server 协议(v1 / v2),RPC 风格;审批请求从 agent 回流到宿主
结论 做法 说明
可借鉴 后端长驻,不随请求起进程 模型与工具状态可以复用,冷启动只发生一次
可借鉴 能力独立进程或独立服务 插件崩溃不拖垮 agent;与本项目 Provider 独立部署是同一个思路
可借鉴 审批从 agent 回流到宿主 策略留在宿主,agent 不自己放行;与 Core 做唯一裁决者一致
不照搬 直接采用它的 app-server 协议 内部演进的协议,两代并存,跟着走会被绑住
不照搬 把会话状态交给它 状态归它所有,我们拿不到控制权,还会出现两份真相
不照搬 按它的规模铺进程 实测整棵树 54 个进程、约 3.25 GB,工厂现场需要评估

形态可学,实现自建:Core 是唯一裁决者,Provider 独立部署且只暴露契约,客户端只做界面与本地守护。要切换引擎的做法见 5.2。