1. 为什么说 Agent 互联是个工程问题,而不是模型问题
如果你最近在一线做 Agent 应用,一定对这种感觉不陌生:模型能力很强,工具也顺手,但一到多 Agent 协作,就开始头疼。我前阵子接手一个跨部门需求,几个团队各维护一套 Agent 系统,有的内部封装了很复杂的状态机,有的直接把大模型输出原样抛给下游,还有的工具调用日志跟任务结果混在一起根本没法解析。结果想把它们串成一条自动化流水线,光做接口对齐就折腾了两周,最后调通的那天,我盯着代码盘里那一堆自造的 JSON 格式,突然意识到问题不在模型,而在“标准”上。
Agent Protocol 要解决的,恰恰是这个“标准”问题。它的核心思路很简单:不关心 Agent 内部用了什么模型、什么记忆机制、什么工具调用策略,只在外部约定一套统一的 HTTP 接口,让任何一个 Agent 都能被当作一个标准的“任务执行服务”来调用。你可以把它理解成 Agent 世界里的 HTTP 协议——HTTP 不管你的服务器用什么语言写的、数据库长什么样,只要它实现了 GET 和 POST 的几个约定,所有客户端就都能跟它通信。Agent Protocol 做的事一模一样,只不过它定义的不是网页语义,而是“提交任务、查询进度、取回结果”这套任务执行语义。
Harness Engineering 这个词,如果之前没听过,先简单解释一下。它指的是围绕大模型构建可控执行环境的那套工程实践——从提示词模板、工具注册、记忆管理,到执行沙箱和结果校验,都属于这个范畴。打个比方,模型是一台高功率发动机,裸着用很容易失控,Harness 就是给它装的变速箱、刹车和仪表盘,让动力能稳稳输出到实际业务里。而 Agent Protocol 处于这套工程体系的最外层,它管的是“这辆装好的车如何被其他车牵引、调度”的问题。没有这一层,单机再强的 Agent 也是数据孤岛,没法被编排进更大的自动化体系。
这篇内容适合三类人看:正在做多 Agent 系统的开发者,想给自己的 Agent 加上标准对外接口;做 AI 应用集成的后端工程师,长期被各种私有协议折磨;以及所有对 Agent 工程化有兴趣、想了解“Agent 之间到底怎么通讯”的人。后面会讲这套协议的核心设计、最小接入实现、适配层方案,以及我实际踩过的坑。按我的经验,把这些看明白了,多 Agent 互联这件事基本就能少走一大半弯路。
2. Agent Protocol 的核心设计拆解
2.1 Task 与 Step:一套任务被拆成两层的用意
第一次看 Agent Protocol 的接口定义时,最容易让人困惑的就是它为什么非要把“任务状态”拆成 Task 和 Step 两层。我当时的第一反应是:一个任务我用一个 status 字段不就完了吗?后来真正接进一个需要跑十几分钟、中间还要调好几个外部工具的任务时,才意识到两层抽象的好处。
Task 是一条完整的任务请求。你可以理解为“帮我把这批销售数据做成图表,发到企业群里”,这是一个从提交到最终产生结果的完整过程。每个 Task 有一个唯一的 task_id,这个 id 是外部系统追踪任务的锚点。Step 是 Task 内部的执行步骤。还是上面那个例子:第一步读取销售数据,第二步调用图表工具生成图片,第三步调用群机器人 webhook 发送消息,每一步都是一个 Step。这样拆的好处是,长任务进行到一半时,外部可以很清楚地知道卡在了哪一步,还能看到每个步骤产生的中间输出,而不是干等一个“运行中”的状态。
这个设计背后其实参考了工作流引擎的思路。设想一个大模型在跑任务时,它可能调用多个工具,每个工具调用都有输入输出,而且这些调用本身可能需要用户审批或人工介入。如果协议层面只有一个扁平的“运行中”状态,那所有这些过程都会黑盒化——出了问题你只能看着转圈发呆。拆成 Task/Step 两层后,外部系统就有了精确的监控粒度:任务在哪一步、这一步的输入输出是什么、下一步还在等什么,全都暴露成标准接口可以查询的数据。
从实现角度看,这两层抽象还带来一个重要好处:协议的实现者不需要在内部做太复杂的推断。Agent 每向前走一步,就向服务端提交一个 Step 记录;整个任务完成时,就标记 Task 为已完成。这种机制对任何框架都是适用的,不管你内部的 Agent 是写一次性的代码任务,还是跑到一半要停下来等人审批,都能映射到 Task 和 Step 这两个概念上。我第一次接入时感觉像把一台复杂的机器拆成了标准的集装箱,虽然里面装的东西五花八门,但箱体大小和卡扣位置是一致的,吊车(外部编排系统)只管吊装就行。
2.2 四个核心端点与一条完整请求链路
具体到接口设计上,常见实现一般会提供四个核心端点,它们组合起来就是一条完整的外部交互链路。看字面你可能觉得简单,但每一步都有它存在的理由,我逐一拆开说。
第一个是创建任务接口。外部系统通过 POST 提交请求,body 里通常包含任务的输入内容、用户上下文、甚至指定要用哪个 Agent 实例。服务端收到后生成一个 task_id 并返回,同时约定:创建任务接口只保证“任务已经接收”,不代表“任务已经开始执行”。这个异步解耦非常关键,因为真实任务的耗时从几秒到几十分钟不等,如果创建任务的请求要等结果回来才返回,那调用方就等死了。
第二个是查询任务详情接口。外部系统拿 task_id 去 GET 任务详情,返回体里包含当前状态,比如排队中、执行中、已完成、失败或已取消,同时会带上当前正在运行的 step_id 或者最近完成的 step 信息。这个接口存在的意义是给外部提供一个拉取式的进度视图,调用方可以用固定的轮询频率不断刷新,也可以在有回调机制的时候作为兜底查询。
第三个是创建 Step 的接口。这一步是 Agent 在执行过程中,每完成一个内部动作时向服务端提交“我要开始下一步了”。它是整个协议里最像“握手”的动作,因为 Step 的输入就是上一步的输出,而 Step 的提交又会在服务端生成一个新的 step_id,并把这个步骤对应的任务推进到执行中的状态。
第四个是查询 Step 详情的接口。外部系统通过 GET 获取某个 Step 的输入输出、状态以及附属的工具调用记录。这个接口是排查问题时使用频率最高的,因为几乎所有中间过程数据都在这里。
用一条真实链路把这些端点串起来就是这样的:外部系统 POST 创建任务,拿到 task_id,然后开始轮询 GET 任务详情。Agent 在内部跑起来后,每完成一个动作就 POST 一个新的 Step。外部系统下次轮询时,就能看到任务详情里指向了最新的 step_id。它再拿这个 step_id 去 GET Step 详情,就能看到这一步做了什么、结果是什么。等到 Agent 内部跑完最后一个动作,任务状态变成已完成,GET 任务详情时就能直接拿到最终结果。整个过程都在标准化的 HTTP 请求里完成,没有任何一个环节需要 Agent 内部暴露私有逻辑。
2.3 认证与安全边界:不是建了接口就能裸奔
协议本身把接口设计得再优雅,如果安全边界没处理好,上线就是灾难。Agent 往往拥有调用外部工具、读写内部系统甚至操作数据库的能力,这意味着不小心把 Agent 接口暴露在公网上,等于把一把机关枪挂在门口。常见的实现里,认证基本都放在 HTTP 头里,用一个 Authorization header 携带访问令牌,服务端校验通过才受理。
我自己在实际接入时更倾向用两层安全策略。第一层是网络隔离,Agent 服务如果只被内部系统调用,就让它跑在内网专段,不开放公网入口;如果确实需要跨网络调用,加一层 API 网关做统一鉴权和流量控制。第二层是令牌管理,每个调用方分配独立的 token,甚至一个外部系统可以分多个 token,这样某个 token 泄露时,可以单独吊销而不是全部换血。另外,凡是创建任务的接口,我都建议在服务端做基础的输入校验,防止有人把超大 JSON 塞进来把内存打爆,或者塞进去恶意指令尝试越权操作。
还有一个容易被忽略的点:最认真的检查事项是回應的查询接口也要校验权限。查询接口如果做成了谁拿到 task_id 就能查,那等于开了一个信息泄露的后门。合理的做法是 token 与调用方绑定,创建任务时记录所属调用方,查询时校验当前 task 是否属于当前 token。这一条看起来是常识,但它确实是我见过最普遍的安全漏洞——大家的注意力都放在创建接口的鉴权上,查询接口反而是重灾区。
3. 从零到一接入 Agent Protocol:实操指南
3.1 最小实现:用 FastAPI 封装一个协议兼容服务
很多人一看协议有四个端点,觉得接入成本很高,其实不是。只要你的 Agent 内部已经有了“执行任务”的函数和“执行单个动作”的函数,把它改成协议服务半天就能搞定。我一般用 FastAPI 来做这件事,因为它天然支持异步、自带数据校验,正好贴合协议这类纯 JSON 交互。下面给一个最小实现的核心骨架,你可以对照自己的项目抄:
from fastapi import FastAPI, HTTPException from pydantic import BaseModel from uuid import uuid4 from datetime import datetime, timezone from typing import Optional, Any app = FastAPI() class CreateTaskRequest(BaseModel): input: Any user_context: Optional[dict] = {} class Task(BaseModel): task_id: str status: str title: Optional[str] = "" details: Optional[dict] = {} created_at: str updated_at: str class Step(BaseModel): task_id: str step_id: str status: str input: Optional[dict] = {} output: Optional[dict] = {} created_at: str updated_at: str # 简化的内存存储,实际请换数据库 tasks_db = {} steps_db = {} @app.post("/ap/v1/tasks") async def create_task(req: CreateTaskRequest): task_id = f"task-{uuid4().hex[:12]}" now = datetime.now(timezone.utc).isoformat() task = Task(task_id=task_id, status="queued", created_at=now, updated_at=now) tasks_db[task_id] = task # 异步触发 agent 执行,便于快速返回 import asyncio asyncio.create_task(run_agent(task_id, req.input)) return {"task_id": task_id} @app.get("/ap/v1/tasks/{task_id}") async def get_task(task_id: str): task = tasks_db.get(task_id) if not task: raise HTTPException(status_code=404, detail="task not found") return task.dict() @app.post("/ap/v1/tasks/{task_id}/steps") async def create_step(task_id: str, step_data: Step): if task_id not in tasks_db: raise HTTPException(status_code=404, detail="task not found") step_id = f"step-{uuid4().hex[:10]}" # 这里要用服务端生成的 step_id 覆盖客户端提交值 steps_db[step_id] = step_data.copy(update={"step_id": step_id, "created_at": datetime.now(timezone.utc).isoformat()}) tasks_db[task_id] = tasks_db[task_id].copy(update={ "status": "running", "current_step_id": step_id, "updated_at": datetime.now(timezone.utc).isoformat() }) return {"step_id": step_id} @app.get("/ap/v1/tasks/{task_id}/steps/{step_id}") async def get_step(task_id: str, step_id: str): step = steps_db.get(step_id) if not step or step.task_id != task_id: raise HTTPException(status_code=404, detail="step not found") return step.dict()这段代码我把容易出错的两个点用注释标了。第一个是 Step 的 step_id 必须由服务端生成,不能信任客户端传的值。客户端只知道“自己要开始执行下一步了”,但唯一 id 的生成和分配权必须留在服务端,否则会出现撞 id 或伪造记录的问题。第二个是创建任务接口一定要用异步方式触发 Agent 执行,不能让任务逻辑把 HTTP 响应阻塞住,否则外部系统会误以为提交失败了。
实际项目中,你还需要把内存存储换成数据库,否则服务一重启任务状态全丢。我当时第一次接进来图省事用内存字典,测试时怎么跑都正常,结果重启服务后轮询直接 404,排查了两小时才反应过来是存储器的问题。这种“能跑但不可靠”的实现,在真实环境里是最坑的。
3.2 适配器模式:不改内部代码也能接入协议
如果你的 Agent 已经跑得好好的,完全不想动它的内部逻辑,那适配器模式是最好的选择。思路很简单:写一个独立的适配层服务,对外暴露 Agent Protocol 接口,对内调用你现有 Agent 的原生接口,做数据结构转换。这就像给一个老式设备做了一个转接头,接口标准变了,但设备本身不用改。
举个例子。假设你现有的 Agent 框架通过 websocket 接收消息,客户端连接后要先发一条初始化指令,然后以特定格式发送任务消息,才能拿到结果。这个流程跟协议要求的“HTTP 创建任务、轮询进度”完全不搭。那适配层就可以在收到 HTTP 创建任务请求时,去建立那个 websocket 连接,把任务消息翻译成旧系统能理解的格式发进去;旧系统每次产生中间结果或执行完一个步骤时,适配层就把它映射成 Step 提交到自己的存储里;外部系统来轮询时,适配层再从存储里把状态读出来返回。整个过程,旧 Agent 完全没有感知到外面发生了什么。
用适配器模式还有一个实质好处:风险低,回滚容易。如果你直接改造内部 Agent 去实现新协议,一旦出问题,内部逻辑跟协议层搅在一起很难排查。而适配层是一个独立服务,出问题时把适配层挂掉,旧系统照常运行,线上业务不为新协议买单。我一般建议团队在从旧架构向协议标准迁移的路上,先走适配器模式,等新技术站得住脚了再考虑要不要把协议层下沉到 Agent 内部。毕竟架构迁移最怕的不是慢,而是改坏了没法收场。
3.3 接入协议后的编排实战:多 Agent 协同一套流程跑通
协议接入本身不是目的,让多个 Agent 真正协作起来才是。我建议用一个最简单的“调度者-执行者”模型入手跑通全链路。调度者不直接干活,它只负责创建任务、轮询状态、把任务结果透传给下一个 Agent;执行者是具体干活的 Agent,接入协议后就变成了一个可被外部调用的标准节点。
举一个我实际跑过的用例:假设有 Agent A 负责做信息收集,Agent B 负责做数据分析,Agent C 负责生成报告。顺着协议走的流程是:调度者先调用 Agent A 创建任务,拿到 task_id 后进入轮询,等 A 完成后取回结果;然后把 A 的结构化结果作为输入调用 Agent B 创建任务,再等 B 完成;最后把 B 的分析结果交给 Agent C 生成最终报告。整个流程里,调度者就像一条流水线旁的传送带,每个 Agent 都是标准接口的工位。各个 Agent 之间没有直接调用,所有交互都通过调度者的中转,这样任何一端的 Agent 出问题,调度者都能及时发现并做重试或降级。
这种风格的多 Agent 协作还有一个好处:完全天然适配异步任务。你看整条链路里,调度者只需要维护“哪些任务在跑、哪些结果已返回”这个状态表,丝毫不需要关心某个 Agent 内部到底跑了几步。Agent 之间的耦合降到了最低,就连调整流程都变得很简单,想加一个 Agent D 做数据校验,只要在 C 之前插入一步“调 D 创建任务并等结果”就行,B 和 C 的代码不用动。我第一次看着这条流水线自动跑完整个流程时,最大的感受是:原来把每一环都变成标准接口,编排复杂逻辑就不需要靠人肉对接了。
4. 常见问题与排查技巧实录
4.1 任务状态莫名丢失或卡在运行中
接入协议后遇到最多的问题就是任务状态丢失。最典型的场景是:创建任务后,第一次轮询能看到状态在跑,隔了一会儿再查,返回 404。我看过太多人在这里的排查思路从协议本身下手,改一堆配置,结果发现就是没做持久化。协议的服务端如果状态只存在内存里,服务进程一重启,所有 task_id 直接蒸发,你再怎么查都不可能查到。
解决思路就一个:任务状态必须落库。而且落库之后还有一个细节,创建任务时的状态写入和触发执行必须放在同一处逻辑里,最好用一个事务包起来。我曾经碰到过一种诡异的情况:任务先落库了,但异步执行线程因为配置错误直接崩了,于是任务状态永远停在“排队中”。表面看是存储问题,实际上是把“存储状态”和“触发执行”拆成了两个流程导致的。把它们变成一个原子操作,每次创建任务要么成功进入执行队列,要么直接返回失败,就不会出现这种悬空状态。
另外,状态卡在“运行中”也很常见,多数是 Agent 内部跑到一半出现了异常,但异常没有被捕获并上报。这时候协议层只能看到一个没有进展的 running。解决方法是写一个兜底的超时任务清理器,扫描超过 N 分钟状态仍未变化的 running 任务,强制标记为失败或需要人工介入。不把这个兜底逻辑做上,生产环境迟早会被卡死的僵尸任务塞满。
4.2 轮询频率与接口压力的博弈
协议本身的交互方式是轮询,这带来的直接问题就是外部系统会疯狂调查询接口。我看过有人写了一个定时器,每 1 秒调一次任务详情,美其名曰“实时监控”,结果 10 个并发任务就把服务端压得喘不过来。这里的问题是:一方面你确实需要及时感知任务状态,另一方面接口压力也不能失控。
我的建议很直接:优先采用间隔递增多级轮询。前 30 秒内按 2 秒一次查,30 秒到 2 分钟内按 5 秒一次查,超过 2 分钟按 10 秒一次查。这个策略对绝大多数任务都能接受,因为大部分 Agent 任务在最初几秒内就会产生第一步的 Step,你可以根据 Step 的更新频率动态调整轮询间隔。如果某个任务 2 秒内没有任何新 Step,说明它可能正在等外部 API 返回,这时候 5 秒查一次完全足够。
还有一个更根本的思路是让服务端主动推送。协议虽然以轮询为主,但你可以在服务端实现一个可选的订阅回调字段,外部系统创建任务时带上一个回调地址,任务状态发生变化时由服务端主动 POST 通知过去。轮询接口保留给回调失败时的兜底查询。这样既能保证实时性,又能大幅降低无意义的查询压力。我一般在大型任务或者跨部门协作场景都强制要求开回调,而轮询只作为调试手段保留。
4.3 多 Agent 串行调用时的竞态与幂等问题
多 Agent 通过协议串行调用时,最常踩的坑就是会产出重复执行的任务。一个典型场景是:调度者创建任务 A,等结果时网络抖了一下,超时了,调度者判断失败,于是重新创建任务 A 准备重试。结果前一个任务 A 其实已经执行成功了,只是结果回传超时。于是同一份工作被执行了两遍——如果是发消息还好,如果是扣款或者写数据库,这就闯大祸了。
解决这个问题的核心是幂等。创建任务接口应当支持客户端传一个业务侧唯一的幂等键,服务端根据幂等键查重,如果相同幂等键的任务已经存在,直接返回已有任务,而不是创建新任务。我在协议实现的存储表上通常加一个业务 id 唯一索引,把对外暴露的 task_id 和对内的幂等键分开管理。
还有一类竞态问题是编排层面的。假设有两个外部系统同时在跑自己的流程,它们可能给同一个 Agent 下发有冲突的任务。比如一个系统让 Agent 更新配置,另一个系统让 Agent 用旧配置执行,后创建的更新任务覆盖了新创建的执行任务的状态数据。这类问题协议本身防不住,需要你在 Agent 内部业务逻辑上做版本号控制,或者保证每个调用方操作的是自己独立的上下文。我的经验是,协议解决的是通讯层的互通,但互通的业务安全,最终还是要靠业务层的机制来兜底。
4.4 版本兼容:协议升级如何不炸掉存量客户端
协议一旦被多个系统接入,升级就成了一个需要步步小心的事。最基础的一条准则:不要在原字段上改语义。比如状态字段里的“running”,如果用新版本来表达“运行中但等待用户审批”,一定不要直接把它改成“waiting”,这会破坏所有旧客户端的状态机判断。正确的做法是新增一个状态值,旧客户端不认识新值只会把它当成未知状态,至少不会因为误判而做出错误动作;但旧值语义被篡改,旧客户端会直接把新行为当成已经学过的旧行为,出错的概率大得多。
另一个常见问题是请求体字段的增减。如果新版本要新增一个必填字段,几乎必然导致旧客户端报错。我的习惯是:新增字段一律设置默认值,服务端处理时缺省就使用默认行为,让旧客户端不需要任何改动也能正常工作;新字段真正想生效时,让调用方显式传值即可。删除字段就更要谨慎,最好先废弃观察一段时间,确认没有任何调用方还在用,再做清理。
还有一个升级策略上的小技巧:在协议兼容设计上,给 Task 和 Step 都留一个可扩展的 metadata 字段。未来想加任何自定义信息都往这个字段里塞,不需要频繁修改核心 schema。我见过太多协议最后被泛化字段塞得一团糟,但那是因为设计者一开始没留扩展口。协议本身并不禁止你加端点,加字段,前提是你给兼容性留好了余地。我自己后来升级协议时几乎从不改动已有字段,新增能力全走 metadata 和新端点的路线,线上的旧客户端从来没人来报过兼容性问题。
5. 几点实用经验和一个可以立刻上手的技巧
这篇文章的最后,我想分享几个自己总结出来的实用经验。第一个是,接入 Agent Protocol 的最大价值不是“你的 Agent 能被别人调”,而是“你的 Agent 终于有了一个清晰的外部边界”。以前做多 Agent 系统,最害怕的就是各团队之间互相暴露自定义接口,你传一个 dict,我传一个 list,最后每个人手里都有一堆只有自己看得懂的胶水代码。有了统一协议之后,代码维护成本和团队协作的沟通成本同时降下来了。
第二个经验是,不要一上来就追求协议的全量实现。先把创建任务、查询任务、查询 Step 这三个核心能力做好跑通,后续再根据需求补认证增强、回调通知和复杂状态扩展。我见过一个团队想把协议所有特性一次全怼上去,结果一个接口都没做完,上线计划整体延期。小步快跑在协议接入这条路上尤其适用。
第三个经验是,无论你怎么设计内部架构,都建议在适配层里保留一份完整的请求日志。这个习惯帮过我大忙。有一次两个 Agent 之间数据对不上,双方都坚持自己传出的格式没问题,最后查日志发现,问题出在中间的编码转换函数把布尔值变成字符串了。如果没有完整的日志链路,这种诡异问题几乎没法定位。
最后分享一个可以立刻上手的技巧:在你服务的健康检查接口里加一个自检任务。每次服务启动或定期执行时,让自己给自己提交一个测试任务,跑一个耗时极短的 mock Agent,验证创建、查询、Step 三个核心链路是否正常。这个技巧是我在生产事故之后养成的习惯。当时线上 Agent 服务挂了一上午,外部调用方一直超时,但因为没有人主动报警,全靠下游系统反馈才发觉。加了自检任务后,服务异常在 5 分钟内就能被监控捕获。它给不了你花哨的功能,但能在关键时刻替你把守住稳定性的最后一道防线。