ERICH TECHNICAL NOTES / 03 返回 Notes
AGENT GATEWAY / ENGINEERING JOURNAL

GATEWAY

从设计工作台到 Agent 网关:让工作流与引擎各自演进

从 Open Design 的使用经历出发,复盘一次三引擎 Gateway 的比赛实践。

看到“多 Agent 引擎可替换架构实现”这道赛题时,我想到的是自己已经遇到过的一个问题:底层 Agent 迭代得这么快,我正在做的应用应该怎样接住这些变化,而不是每换一个引擎就重写一遍?

此前我用 Open Design 给 AI DevFlow Studio 做过界面设计。它把设计方法、设计系统和产物组织放在上层,让已有的编码 Agent 去执行。这种分工给了我一个判断:应用应该长期打磨自己的工作方法,同时通过一份清楚的契约接入外部引擎。Open Design Agent Gateway 就是我把这个判断放到比赛里的一次实践。

这个判断后来经受了一次真实的检验:比赛后期,项目退役了一个引擎、又接入了五个,引擎契约的方法一个都没有改。这篇文章从赛题讲起,一直讲到这次检验。

从赛题出发

我们参加的是华为内部的质量与流程 IT 首届 AI 争霸赛,选择“多 Agent 引擎可替换架构实现”这道题,最终获得二等奖,赛题排名 2/46,总榜排名 5/307。我担任项目主程,负责方案、交互设计、开发、测试和交付;组员协助多台 Windows 设备的测试、方案评估和参考项目讨论。

赛题的要求很具体:应用对外实现统一的网关接口,底层至少接入两种不同的 Harness,支持 Windows,并能通过配置选择引擎。评测任务面向办公场景,包括文档修改、数据分析、演示文稿、文件操作、信息检索,以及通过 WeLink 发送消息。项目总纲与赛题约束(在新标签页打开来源)

这里要先分清两个概念。Harness 是承载 Agent 执行过程的软件:它组织模型调用、工具使用、上下文和任务运行,比如 OpenCode、Pi。模型 Provider 只提供推理服务。开发验证中各个 Harness 都调用 DeepSeek 模型,这并不等于多接了一个“DeepSeek 引擎”。项目里确实另有一个实验性的 DSH(DeepSeek Harness),但它没有进入这一阶段的三引擎基线。

我把这道题理解为两种演进速度之间的协调:业务在不断增加自己的流程和交付要求,Agent 生态在不断改进执行能力。目标是让业务接口、任务工具和验收方式保持稳定,同时让底层引擎可以替换。

比赛之前,我已经用过 Open Design

6 月,我把 DevFlow 的产品背景交给 Open Design:一个本地 Electron 工作台,要承接需求澄清、方案设计、编码、测试、PR 交付与验收,界面还要表达 Run、Gate、Artifact 和 Trace 之间的关系。

那段原型迭代的重点不是换配色。我反复追问工作台、Workflow Board 和 Inspector 应该怎样分工,Team Project 和 Local Project 会不会被混淆,Gate、知识、产物和执行记录之间的关系有没有真正画出来。这些问题决定了使用者能不能一眼看出“现在在哪里、为什么不能继续、下一步是什么”。

这段经历留下了可检查的产物。DevFlow 仓库在 6 月 23 日加入了原型参考,两天后归档了完整设计包。设计说明保留了 Local Project + Runs、Workflow Board、Inspector 三个区域,并写明它是视觉与交互参考,不是要直接嵌入的静态 HTML;移植到 React/Electron 时必须保留业务概念和交互关系,尤其要让 Team Project 与 Local Project 在视觉上分开。设计参考与移植规则(在新标签页打开来源) · 原型参考提交(在新标签页打开来源)

对我最有启发的,是设计约束可以和执行它的 Agent 分开。Open Design 负责工作台、指导和产物组织,底层 Agent 负责读项目、改文件、返回结果。上游的 Agent Adapters 文档写得很清楚:Agent 循环委托给外部 CLI,每种 Runtime 只是一份声明式定义,描述可执行文件、参数、能力和输出格式,本身不带 run() 或 cancel()。上游 Agent Adapters(在新标签页打开来源) OpenDesign 在 5 月的官方文章(在新标签页打开来源)里也讲过这种用 skill 和适配层复用现有 Agent 的思路。

后来做 Gateway 时,我在这里做了一个关键的改变:Gateway 的引擎契约不能只是声明式的数据。比赛接口要把 Agent 的反问和权限请求转给客户端,还要为每一轮任务给出唯一的终态,所以驱动必须是“可以调用的对象”。这一点在后面的 EngineDriver 里会看到。

方法留在应用层

这和我做 DevFlow 时关心的事情一致:真正值得长期积累的,是一套从需求走到交付的方法,包括每个阶段产出什么、怎样检查、由谁决定继续。执行引擎通过契约参与这套方法,而不是反过来。

DevFlow 的六个阶段是 Clarify → Design → Build → Test → PR → Accept,展开是 8 个节点:需求确认 Gate 要求团队成员批准,方案评审 Gate 和最终验收要求负责人批准。工作流模板源码(在新标签页打开来源) 2026 年 8 月 12 日通过的 ADR 0015 进一步规定:OpenCode 和 DevFlow 自己的受限 Native 执行器实现同一套 Coding Executor 契约,共用能力声明、事件、取消和结果结构;执行器不能批准 Gate,也不能发布;取消或终态之后到达的事件不能再提交。ADR 0015(在新标签页打开来源)

后来我读到 Anthropic 于 2026 年 8 月 21 日发布的《The AI-Native SDLC playbook》,里面有相近的关注点:代码生成加速之后,要重新组织编码前后的流程,让阶段产物接续,并保留人的判断。它的六个阶段是 Plan、Design、Build、Test、Deploy、Maintain,和 DevFlow 并不相同:DevFlow 单独分出了 PR 和业务验收,Anthropic 则覆盖部署与维护。Anthropic 原文(在新标签页打开来源) DevFlow 六月的原型早于这篇文章,所以这只是事后看到的呼应,不是设计来源。

带着这个思路进入比赛,我给 Gateway 的分工是:网关负责稳定的业务契约和运行边界,Harness 继续发展自己的执行能力,共享的任务工具承担可以确定性处理的文件和计算工作。

一份契约,三种协议

比赛实现固定在 Open Design v0.21.1 上,通过保留历史的 Git subtree 导入。这样上游原有的内容和项目后来的改造可以分开追溯。上游导入记录(在新标签页打开来源)

下面这张图按实际调用关系重画。上方是比赛请求的主链,下方分别是直接复用的基础能力和仓库里保留、但不在主链上的产品代码。

Open Design Agent Gateway 架构:比赛客户端经过 HTTP/SSE、会话服务与 EngineDriver,连接启动时选定的一个 Harness;直接复用跨平台进程和引擎发现基础,原产品工作台与数据库另行保留。
图 1 · 比赛主链、直接复用与保留的产品面。按 Gateway 4296525bd 的源码绘制。

启动入口读取 --engine,没有时再读 AGENT_ENGINE;两者都没有就直接报错,没有默认引擎。它据此创建一个 Driver,再启动比赛用的 HTTP/SSE 服务。一个 Gateway 进程从启动到退出只绑定一个引擎,切换引擎需要重启;它不会在任务中途自动挑选引擎,也不会把会话迁移到另一种 Harness。启动与装配源码(在新标签页打开来源)

HTTP 层之后,GatewaySessionService 管理会话、消息、交互和忙闲状态;原生协议的差异全部收在三个 Driver 里。它们共同实现的契约是这样的:EngineDriver 定义(在新标签页打开来源)

export interface EngineDriver {
  readonly capabilities: EngineCapabilities;
  start(): Promise<void>;
  createSession(input: EngineSessionInput): Promise<EngineHandle>;
  runTurn(handle: EngineHandle, input: EngineTurnInput,
          sink: EngineEventSink): Promise<EngineTerminal>;
  replyInteraction(handle: EngineHandle, reply: InteractionReply): Promise<void>;
  abort(handle: EngineHandle): Promise<void>;
  closeSession(handle: EngineHandle): Promise<void>;
  dispose(): Promise<void>;
}

每一轮任务只会以四种终态之一结束:completed、aborted、failed 或 timed_out。引擎的原生事件被翻译成统一的六类事件:助手文本、工具调用、工具结果、步骤结束、反问和权限请求。

引擎 实际接入方式 保留下来的差异
OpenCode 1.18.18 每个 Driver 启动一个本机 opencode serve,通过 SDK 调用并消费事件流 一个服务承载多个会话;反问和权限都能转发
Pi 0.84.4 每个会话启动一个 pi --mode rpc 子进程,通过 stdio 上的 JSONL 通信 项目自带的扩展注册 ask_user 工具,并在只读工具之外的调用前请求一次、始终或拒绝
Hermes 0.21.0 每个会话启动一个 ACP 进程,依次 initialize、session/new、session/set_model 权限可以转发;当前 Adapter 不支持反问

这些差异直接写在各自的启动参数、初始化流程和 capabilities 里。OpenCode Driver(在新标签页打开来源) · Pi Driver(在新标签页打开来源) · Pi 扩展(在新标签页打开来源) · Hermes Driver(在新标签页打开来源)

执行期间,真正调用模型、选择工具的仍然是 Harness。项目把同一套 Office/WeLink Skill 和一个共享 CLI 显式交给每个 Driver:Skill 说明处理顺序和约束,CLI 负责读文件、改文件、做统计。但“同一套”落到三个引擎上是三种形式:OpenCode 在配置里接收 Skill 目录,Pi 用 --skill 接收文件,Hermes 则把 Skill 目录复制到自己的 home 下,并在提示词前加上对应的斜杠命令。共享工具装配(在新标签页打开来源) 通用工具修好一次,三个引擎都能用上。

一次任务的完整回合

一次 Gateway 任务:客户端建立 SSE 与会话,提交需求后由启动时选定的 Harness 执行模型及工具循环,必要时回复交互;正常完成或取消后返回 204,失败和超时进入错误响应,客户端仍需核对实际产物。
图 2 · 一次任务的调用顺序。交互回复属于原 turn;HTTP 204 与产物正确性分别判断。

一个容易误读的接口是 prompt_async。名字听起来像“收到请求就返回”,实际上路由会等会话服务把这一轮跑完,才返回 HTTP 204:HTTP 实现(在新标签页打开来源)

if (method === "POST" && prompt?.[1]) {
  const body = await readJson(request);
  await this.service.prompt(decodePathId(prompt[1]), validatePrompt(body), directory);
  writeNoContent(response);
  return;
}

执行中的文本、工具调用和交互请求通过 SSE 实时推送。正常完成和取消都返回 204,但取消不会被伪装成一条正常的助手回复;失败和超时返回 502,同时推送一条 session.error。每轮默认有 30 分钟的时限,超时后会同时中止底层引擎。会话服务还给每一轮发放一个令牌,已经结束的那一轮不会再被迟到的事件改写。会话服务(在新标签页打开来源)

所以 204 只说明这一轮处理结束了。客户端还要检查最终消息、终态和实际文件。项目自己的评测器就是这样做的:它从公开的 HTTP/SSE 接口进入,等到 session.idle 之后再读取消息并检查产物。把这些行为收敛在会话层,是接入第二、第三种引擎之后,客户端看到的行为仍然一致的关键。

接口之外的两次故障

Git 历史能还原主要的实施顺序:9 月 3 日导入上游并完成第一个多引擎 Gateway,当时接入的是 OpenCode 和 Pi;9 月 4 日到 5 日补上 V1.3 评测流水线、只读的 Judge,并接入 Hermes ACP;之后持续修复工具、上下文隔离、Windows 兼容和交付依赖。首个多引擎实现(在新标签页打开来源) · Hermes 接入(在新标签页打开来源) 真正让我改变认识的,是其中两次故障。

第一次:参数传了,Skill 却没有加载。 共享接口传给引擎的是 SKILL.md 的文件路径,而 OpenCode 的配置要求的是 Skill 所在的目录。用真实的 opencode debug skill --pure 检查,给文件路径时共享 Skill 不在列表里,给目录时才出现。V1.5 的第一批全量评测在确认这一点后被停止,已完成的记录保留、标记为不计入比较。修复(在新标签页打开来源)把转换放进了 OpenCode Driver:

// Our shared interface names SKILL.md files; OpenCode scans directories instead.
skills: { paths: [...new Set(skillPaths.map(skill =>
  path.resolve(path.basename(skill) === "SKILL.md" ? path.dirname(skill) : skill)))] }

同时补上真实的 Skill 发现和模型加载检查。这正是 Adapter 存在的意义:契约统一了“要给引擎什么”,每个 Driver 负责把它翻译成引擎真正能理解的形式。

第二次:Pi 读到了旧答案。 第二批全量评测运行中,从公开的会话消息接口可以看到,Pi 在处理一道演示文稿任务时读取了旧批次的产物记录、复查汇总、旧的 Judge 输出和旧的 PPT。原因是 Pi 会自动加载上层目录里的 AGENTS/CLAUDE 上下文文件,而评测工作区当时就放在仓库内部。那一批从未进入评分。

修复分三处:Pi Driver 加上 --no-context-files,所以它对所有 Gateway 中的 Pi 会话都生效,而不只是评测;每次执行改在仓库之外的独立临时目录进行;执行结束后把工作区复制归档,并比对前后的文件哈希清单。执行目录与归档(在新标签页打开来源) 隔离后重跑同一道题,Pi 调用了 38 次工具,没有读取任何历史材料。这里控制的是评测上下文和产物来源,并不是操作系统级的沙箱。V1.5 记录(在新标签页打开来源)

处理这次问题时,项目没有用加大输出预算的办法去掩盖同批出现的 length 错误,而是先解决隔离。第三批冻结后,三个引擎共完成 27 次真实 DeepSeek 执行,评测细节写在另一篇笔记里。后续范围扩展到包含 WeLink 在内的 30 项任务,我在公司的 Windows 环境上确认跑通并写入了现场记录(在新标签页打开来源);对应的脱敏运行目录和最终包的独立复验还没有补齐。

这两件事让我看清:引擎可替换只是第一步。同一个输入交给不同的 Harness 之后,还要确认它们拿到了正确的指导、只用了允许的上下文、留下了真实的产物。

复用、保留与没有做的

打开 Open Design 仓库,很容易被它的功能数量吸引。解释这个项目时,我更愿意沿调用关系说明每一部分的归属:

范围 在 Gateway 中的实际关系
跨平台命令、进程树处理 直接复用上游 platform 包,RuntimeHost 调用它的命令封装、进程枚举和停止逻辑
引擎发现、可执行文件解析 部分复用:先用比赛固定的安装位置或显式配置,再用上游的注册表和解析逻辑兜底
HTTP/SSE、会话服务、EngineDriver 本项目新增,比赛协议和引擎适配放在独立的包里
Office/WeLink 工具、开发评测 本项目新增,三个引擎共用任务工具,评测单独观察和检查结果
Web/Electron、产品 daemon、SQLite、插件与 sidecar 保留的上游产品能力,不在比赛请求的链路上

这些关系可以从 RuntimeHost(在新标签页打开来源) 的实际引用和 CLI 的装配顺序交叉核对;上游的 platform、runtime 和插件目录与导入基线的 Git 对象完全一致,不能算作本项目的原创。架构归属说明(在新标签页打开来源)

这些取舍也留下了清楚的边界:比赛会话只保存在内存里,没有接入产品 daemon 的数据库;没有进程内热切换、跨引擎会话同步或自动故障转移;WeLink 的发送记录和回执另行持久化,但这不代表整个会话已经持久化。评测器遇到反问时自动选第一个选项、遇到权限请求时自动批准一次,所以批量评测只证明交互链路能走通,不代表这些决定经过了人的判断。比赛服务默认只监听本机,没有 HTTP 认证和 TLS;而权限回复接口实际上能批准工具执行,如果要变成共享服务,认证、TLS 和网络访问控制都必须补上并单独验证。当前实现与边界(在新标签页打开来源)

后来:退役一个引擎,又接入五个

9 月中旬之后,项目转入决赛准备,这份契约迎来了一次真实的检验。

9 月 17 日,Hermes 被退役,它的 Adapter、补丁和 Python 运行环境一并删除,ACP 传输层移到了中立的目录;记录里写下的直接好处是,一台新的 Windows 机器不再需要安装 Python 3.12 和专门的补丁。同一天接入了 Qwen Code 和 Kimi Code,之后又陆续接入 Cline、DSH、Kilo 和 MiMo,基线变成了八个引擎。退役与迁移提交(在新标签页打开来源) · 八引擎清单(在新标签页打开来源)

它们的原生协议各不相同:OpenCode、Kilo、MiMo 走 HTTP 加 SDK,Qwen、Kimi、Cline、DSH 走 stdio 上的 ACP,Pi 仍然是 JSONL RPC。但从 9 月 9 日的固定提交到 9 月 20 日的主干,EngineDriver 的方法一个都没有变。对比两个版本的 types.ts(在新标签页打开来源),唯一的改动在运行时宿主上:解析可执行文件时接受的引擎名称,从四个写死的值换成了八个引擎的联合类型。

并发也是在这份契约之上加的:最多四个不同的 Harness 同时运行,每个 Harness 同一时间只处理一个任务,Judge 最多两路并发,WeLink 保持单通道;一个 run 仍然只绑定一个引擎。并发实现(在新标签页打开来源) 这些是代码层面的事实,不能说明各个引擎的结果质量。决赛的 32 项组合在记录里明确标注为“计划 ≠ 通过”,仓库中也没有任何决赛成绩。

回头看,从 Open Design 到 DevFlow,再到这次比赛,我愿意长期维护的是这几件事:任务怎样描述,流程怎样推进,交互怎样表达,结果依据什么被接受。Gateway 把这些问题变成了一份可以检查的接口。换一种 Harness,仍然要安装、配置、声明能力、映射协议、用真实任务验证,适配成本并没有消失;但这些工作有了固定的落点,应用本身的方法和交付质量,也就不必跟着每一次引擎更替重新来过。


技术说明对应 Gateway 比赛阶段的固定提交 4296525bd、Open Design v0.21.1,以及文中列出的 DevFlow 固定版本文件;最后一章对应 9 月 20 日的主干版本。两张图根据比赛阶段的源码绘制。历史运行结果来自当时保存的报告和记录,本文写作时没有重新运行模型任务。比赛名称、奖项、排名、分工和个人动机依据我的参赛记录;六月的使用经历结合了本地设计对话和仓库里的原型归档。本文不公开内部聊天、企业地址、测试原始文件或凭据。

延伸阅读: