后端约定

这一页描述内置适配器实际读取的格式和 SDK 接入约定,可供后端团队对接。它不是外部协议官方规范或版本兼容清单;示例扩展需由部署明确支持。

结论

  • 能力是一项一项独立的。接入方提供函数后启用对应能力,界面入口还受配置与当前状态约束。只有「发起一轮」是必需的。
  • 停止、续接、插话、重新生成都靠轮次编号指明是哪一轮。同一轮从头到尾用同一个编号。
  • 续接靠事件编号指明从哪之后接着发。协议的事件没有编号时,放在 SSE 的 id: 字段里。
  • 历史里每条消息要带消息编号、所属轮次编号、时间;每一轮要带结果。
  • 这份约定不规定地址、请求方式和鉴权。它们由前端的接入方在自己的函数里决定,和后端团队商定即可。

前端这一侧的分工

角色做什么
适配器只做协议翻译:把请求内容按协议编好,把后端的响应按协议解析
接入方为每项能力写一个发请求的函数:请求发到哪个地址、带什么凭据、用哪个模型
浏览器在内存保留会话与消息,不写浏览器持久存储。页面重建后能看到什么,完全取决于后端的历史接口返回了什么

后端返回的格式和这里写的不一样时,由接入方在自己的函数里转换,见后端不符合约定时。

几个名词

名词意思
轮次一次主 Agent 执行,通常由用户发送触发,也可由远端主动触发;一轮可包含多条消息和多次工具调用
轮次编号一轮的唯一编号
事件后端在流里发给前端的一条数据
事件编号一个事件的稳定游标,在一轮里唯一;SDK 不比较不透明游标大小

每项能力后端要做到什么

能力界面上的功能请求里有什么后端要做到
发起一轮发送消息、流式显示回答会话编号、轮次编号、按协议编好的请求内容无 subscribe 时返回协议流;有 subscribe 时返回受理响应、输出走订阅。同一轮使用同一个轮次编号
停止停止按钮通知到后端会话编号、轮次编号返回成功仅表示取消命令受理;最终取消需由执行事件或历史快照确认。拒绝受理返回非成功状态
续接断线或刷新后接着显示会话编号、轮次编号、最后收到的事件编号按事件编号重放之后的完整后缀,包括终态;有 subscribe 时重放到订阅,否则返回流
插话回答进行中追加一句话会话编号、轮次编号、按协议编好的追加内容把内容送进这一轮,返回成功状态。这一轮已经不接受时返回非成功状态
重新生成 / 编辑后重发回答下方的重新生成、编辑同「发起一轮」,多一个被重做那一轮的编号把被重做的那一轮和它之后的内容作废,再执行新的一轮。历史里不能再出现作废的内容
历史打开会话、刷新后看到旧内容会话编号、翻页游标(第一页没有)返回这个会话的消息:每条消息带消息编号、所属轮次编号、时间;每一轮带结果(完成、失败、中断,失败带原因)

通用的规则

规则说明
成功与失败看状态码成功返回 2xx。失败返回非 2xx,响应内容里写明原因,建议 { "error": { "code": "...", "message": "..." } },message 会显示给用户
不要用 200 表示失败不要返回 200 再加一个错误字段。A2A JSON-RPC 错误和独立订阅 JSON 受理响应的顶层 error 会另行检查,见下文
有些响应只看状态码改名、删除只看状态码;停止、插话通常也只看状态码,但 A2A 还检查 JSON-RPC error
停止失败保持未确认继续接收输出,等待终态事件或历史确认,不伪造中止
前端会取消请求用户停止一轮时取消本轮请求,释放会话时取消会话监听;切换会话保留后台接收。后端需支持请求取消,不能把断开当作执行完成。后端要能处理连接中途断开
前端给的编号不要解析前端轮次编号是实现细节,后端当成不透明字符串处理

轮次编号

发起命令时使用本地 runId;直接流读到后端身份后,ADK、A2A、Hermes 保存映射,后续能力按各自规则使用后端编号。独立订阅在这一步之前分发,本地命令的事件路由编号必须与命令一致,不能指望受理响应自动建立映射。

协议一轮的编号后端要做的
OpenAI 兼容接口协议没有。流块的 id 标识的是一次模型调用,不是一轮认前端给的轮次编号:接入方在发起时把它放进请求,后端存下来,停止、续接、历史里都用它
ADK事件上的 invocationId同一轮的事件带同一个 invocationId。停止、续接、插话的请求里拿到的就是它
Hermes流里 run.started 事件的 run_id每一轮先发 run.started。停止、续接、插话的请求里拿到的就是它
A2A任务编号 taskId同一轮的事件带同一个 taskId。停止、续接、插话的请求里拿到的就是它

同一轮用同一个编号

同一轮用同一个轮次编号正确整轮一个编号请求模型轮次 r-1调用工具轮次 r-1再请求模型轮次 r-1给出回答轮次 r-1错误每次调用一个编号请求模型轮次 a调用工具轮次 a再请求模型轮次 b给出回答轮次 b历史读回来,一轮会变成两轮;停止、续接找不到完整的这一轮。
同一后端轮次使用稳定编号;本地编号可不同。直接流保存映射,独立订阅需先统一路由身份。

后端如果按「每次模型调用一个编号」来存,历史读回来一轮会变成好几轮。

事件编号

规则同样只有一条:协议的事件自带编号的,用协议的;没有的,放在 SSE 的 id: 字段里。

协议事件编号
OpenAI 兼容接口SSE 的 id: 字段
ADK事件的 id
Hermes事件的 seq。没有 seq 时取 SSE 的 id:
A2ASSE 的 id: 字段

SSE 的 id: 字段是传输层的东西,不改变协议的数据内容:

id: e17
data: {"choices":[{"index":0,"delta":{"content":"你好"}}]}
情况结果
后端不支持续接不用带事件编号
带了事件编号,接入方没有给续接函数可记录续接位置,不保证统一去重,也不能重放缺失事件
给了续接函数,流里没有事件编号不能续流;有 history 则补齐,否则结果未知

续接的过程

续接的过程界面与 Core适配器能力函数接入方写后端run 返回的流:事件 e1、e2、e3记下最后收到的事件编号:e3连接断开。这一轮还没有结论,留在进行中续接这一轮调用 resume轮次编号、事件编号 e3发请求带上轮次编号和 e3只发 e3 之后的:e4、e5、结论接在同一条回答后面
续接靠两个编号:轮次编号指明是哪一轮,事件编号指明从哪之后接着发。

后端要做到两点:

  1. 把一轮里发过的事件按顺序存下来,存到这一轮结束之后一段时间。
  2. 收到续接请求时,从指定的事件编号之后接着发。直接流应排除重复增量;并非所有适配器都会按后端事件编号去重。

协议里没有的信息放在哪

以下列出各适配器读取的位置。runs、active_run、metadata.lastEventId 等属于本文接入约定,不能因字段名相似就当成外部官方标准:

信息OpenAI 兼容接口ADKHermesA2A
消息所属的轮次消息上的 run_id事件的 invocationId(协议)消息上的 run_id消息所在的任务(协议)
消息编号消息上的 id事件的 id(协议)消息的 id(协议)消息的 messageId(协议)
消息时间消息上的 created_at事件的 timestamp(协议),界面不显示消息的 timestamp(协议)协议没有,界面不显示
每一轮的结果历史顶层的 runs 数组仅重建内容,不生成终态快照历史顶层的 runs 数组任务的 status.state(协议)
还在进行的轮次runs 里 status: running见下文「ADK」runs 里 status: running任务状态是 submitted / working
续接用的最后事件编号runs 里的 last_event_idactive_run.event_id(与历史前缀匹配)runs 里的 last_event_id任务的 metadata.lastEventId

时间的写法:

位置认哪些写法
会话上的时间Unix 秒、Unix 毫秒、ISO 8601 字符串
OpenAI 兼容接口和 Hermes 的历史Unix 秒或毫秒
ADK 的 lastUpdateTimeUnix 秒
A2A 任务的 status.timestampISO 8601 字符串

会话目录由应用管理

Adapter 不规定目录接口或响应格式。应用将业务响应映射为 SessionInfo 数组,并传给受控 SessionList;创建、改名、删除、fork 与路由由应用实现。Adapter 的 history 只查询一个 sessionId 的消息历史。

OpenAI 兼容接口

流

本适配器读取 Chat Completions chunk。直接 HTTP 流识别 [DONE];finish_reason 标记模型步骤已有结论,读取器仍等待流结束,工具调用可能继续下一步模型请求。异步序列使用 finish_reason 后结束迭代,不传字符串 [DONE]。 出错可以用非 2xx 状态码,也可以在流里发一个带 error 对象的块。

要点说明
请求内容里没有模型名由接入方在函数里加
请求内容里没有轮次编号由接入方在函数里加。字段名由后端和接入方商定,例如 run_id
要支持停止、续接、历史后端要从请求里读到前端给的轮次编号并存下来
工具由浏览器执行时一轮里前端会请求多次模型,每次带上工具的结果。这几次请求的轮次编号相同

历史

OpenAI 的消息数组,加上约定的字段。

{
  "messages": [
    { "id": "m0", "created_at": 1760000000, "role": "system", "content": "你是助手" },
    { "id": "m1", "run_id": "s-1:run:ab12:1", "created_at": 1760000001, "role": "user", "content": "北京天气" },
    {
      "id": "m2",
      "run_id": "s-1:run:ab12:1",
      "created_at": 1760000002,
      "role": "assistant",
      "content": "我查一下",
      "reasoning_content": "需要查天气",
      "tool_calls": [
        { "id": "call-1", "type": "function", "function": { "name": "weather", "arguments": "{\"city\":\"北京\"}" } }
      ]
    },
    { "id": "m3", "run_id": "s-1:run:ab12:1", "role": "tool", "tool_call_id": "call-1", "content": "{\"temp\":20}" },
    { "id": "m4", "run_id": "s-1:run:ab12:1", "created_at": 1760000004, "role": "assistant", "content": "北京 20 度" },
    { "id": "m5", "run_id": "s-1:run:ab12:2", "created_at": 1760000010, "role": "user", "content": "上海呢" },
    { "id": "m6", "run_id": "s-1:run:ab12:2", "created_at": 1760000011, "role": "assistant", "content": "上海" }
  ],
  "runs": [
    {
      "id": "s-1:run:ab12:1",
      "status": "completed",
      "created_at": 1760000001,
      "finished_at": 1760000005,
      "model": "model-a",
      "usage": { "total_tokens": 30 }
    },
    { "id": "s-1:run:ab12:2", "status": "running", "last_event_id": "e42" }
  ],
  "cursor": "before-m0",
  "has_more": true
}
字段必填说明
messages是从早到晚。同一轮的消息挨在一起,顺序和当初请求模型时一致:user → assistant(带 tool_calls)→ tool → assistant …
messages[].id是消息编号,会话内唯一
messages[].run_id否所属轮次的编号。system 消息可以没有;其余消息没有的话不归属任何一轮,不能重新生成
messages[].created_at否产生时间,Unix 秒(给毫秒也认)
messages[].reasoning_content否助手消息的思考过程
runs否每一轮的结果。未列出的轮次不推断执行结果
runs[].id是轮次编号
runs[].status是completed、failed、interrupted(被停止)、running(还在进行)
runs[].error否失败原因,{ "code": "...", "message": "..." }
runs[].last_event_id否这一轮最后一个事件的编号。还在进行的轮次要续接就必须给
runs[].created_at / finished_at否开始、结束时间
runs[].model / usage否用的模型、用量
cursor / has_more否翻页:cursor 是取更早一页用的游标

tool 消息的内容是 JSON 字符串时前端会解析。内容恰好是 { "error": "原因" }(只有这一个键)时认作工具调用失败。

ADK

流

run_sse 的事件流,每个事件带 id 和 invocationId。正常结束需要 data: [DONE] 或约定的明确结束事件;普通连接关闭不代表执行完成, 出错发带 errorCode / errorMessage 的事件。

要点说明
请求内容sessionId、streaming: true、newMessage。没有应用名和用户 ID,由接入方加
发起一轮之前会话要已经存在会话不存在的状态码由部署决定;确认是不存在后再由接入方创建

续接

请求内容是 { "sessionId", "streaming": true, "lastEventId" },不带 newMessage。 后端只发 lastEventId 之后的事件。

ADK 会话详情未提供执行状态时,适配器不猜测最后一轮仍在运行。需要自动续接时,后端或 history 函数组合的响应应明确包含:

{ "session": { "status": "running" }, "active_run": { "run_id": "invocation-123", "event_id": "event-45" } }

事件编号必须对应历史消息已经包含的前缀。active_run 为 null 表示已知无活动执行;缺省表示未知,不自动续接。ADK resume 的非 2xx 响应文本匹配 no active or completed stream 或 without last_event_id 时返回 false;这不补造完成结论,其余错误正常上报。

历史

会话详情,历史就是里面的 events,从早到晚。

{
  "id": "s-1",
  "state": { "title": "北京天气" },
  "lastUpdateTime": 1760000005.2,
  "events": [
    {
      "id": "e1",
      "invocationId": "inv-1",
      "author": "user",
      "timestamp": 1760000001,
      "content": { "role": "user", "parts": [{ "text": "北京天气" }] }
    },
    {
      "id": "e2",
      "invocationId": "inv-1",
      "author": "model",
      "content": {
        "role": "model",
        "parts": [{ "functionCall": { "id": "call-1", "name": "weather", "args": { "city": "北京" } } }]
      }
    },
    {
      "id": "e3",
      "invocationId": "inv-1",
      "author": "model",
      "content": {
        "role": "user",
        "parts": [{ "functionResponse": { "id": "call-1", "name": "weather", "response": { "temp": 20 } } }]
      }
    },
    {
      "id": "e4",
      "invocationId": "inv-1",
      "author": "model",
      "content": { "role": "model", "parts": [{ "text": "北京 20 度" }] }
    }
  ]
}

Hermes

流

会话接口的事件流,每帧是 event: <名字> 加 data: <JSON>:

run.started → assistant.delta / tool.* → run.completed(或 run.failed / run.cancelled / run.interrupted)→ done
要点说明
事件编号优先读 seq,没有时读 SSE id;不比较游标大小
请求内容input 和可选的 instructions。没有模型、服务商,由接入方加
会话不存在404 + session_not_found 的 history 按空历史处理;run 不自动创建
插话请求内容是 { "input": "追加的文字" }。这一轮已经不接受追加时返回 409

历史

会话的消息列表,加上约定的字段。

{
  "data": [
    { "id": 1, "run_id": "run_a", "role": "user", "content": "北京天气", "timestamp": 1760000001 },
    {
      "id": 2,
      "run_id": "run_a",
      "role": "assistant",
      "content": "我查一下",
      "reasoning": "需要查天气",
      "tool_calls": [{ "id": "c1", "function": { "name": "weather", "arguments": "{\"city\":\"北京\"}" } }]
    },
    { "id": 3, "run_id": "run_a", "role": "tool", "tool_call_id": "c1", "content": "20 度" },
    { "id": 4, "run_id": "run_a", "role": "assistant", "content": "北京 20 度" }
  ],
  "runs": [{ "id": "run_a", "status": "completed", "created_at": 1760000001, "finished_at": 1760000005 }]
}
字段必填说明
data是消息,从早到晚。display_kind: "hidden" 的和 system 消息不显示
data[].run_id否所属轮次的编号,和这一轮 run.started 给的 run_id 相同
runs否每一轮的结果,字段同 OpenAI 兼容接口的 runs(没有 model / usage);last_event_id 是事件的 seq
情况前端怎么处理
消息上没有 run_id显示不受影响。不知道每一轮的结果,不能续接和重新生成历史里的轮次
会话还不存在(404 加 session_not_found)按「没有历史」处理,不算错误

A2A

流

JSON-RPC over SSE,每帧的 data 是一个 JSON-RPC 响应,result 里是 A2A 的事件(task、status-update、artifact-update)。 执行结论按任务状态映射;final: true 本身不代表 completed。直接 HTTP 流另接受 [DONE] 作为 SDK 兼容完成标记。出错发带 error 的 JSON-RPC 响应。

请求内容是填好的 JSON-RPC 信封,接入方原样发出:

能力methodparams
发起一轮、重新生成、编辑后重发message/stream{ message },message.contextId 是会话编号
停止tasks/cancel{ id: 任务编号 }
续接tasks/resubscribe{ id: 任务编号 }。最后的事件编号由接入方放进请求头或参数
插话message/send{ message },message.taskId 是进行中的任务

停止和插话的响应是普通的 JSON-RPC 响应:状态码成功、内容里又没有 error 才算成功。

历史

这个会话(contextId)下的任务列表,从早到晚。一个任务就是一轮。

{
  "tasks": [
    {
      "kind": "task",
      "id": "task-1",
      "contextId": "s-1",
      "status": { "state": "completed", "timestamp": "2026-01-02T03:04:05Z" },
      "history": [
        { "kind": "message", "messageId": "u1", "role": "user", "parts": [{ "kind": "text", "text": "北京天气" }] },
        {
          "kind": "message",
          "messageId": "m1",
          "role": "agent",
          "parts": [
            {
              "kind": "data",
              "data": { "id": "call-1", "name": "weather", "args": { "city": "北京" } },
              "metadata": { "adk_type": "function_call" }
            }
          ]
        },
        {
          "kind": "message",
          "messageId": "m2",
          "role": "agent",
          "parts": [
            {
              "kind": "data",
              "data": { "id": "call-1", "name": "weather", "response": { "temp": 20 } },
              "metadata": { "adk_type": "function_response" }
            }
          ]
        }
      ],
      "artifacts": [{ "artifactId": "a1", "parts": [{ "kind": "text", "text": "北京 20 度" }] }]
    },
    {
      "kind": "task",
      "id": "task-2",
      "contextId": "s-1",
      "status": { "state": "working" },
      "metadata": { "lastEventId": "e42" },
      "history": [
        { "kind": "message", "messageId": "u2", "role": "user", "parts": [{ "kind": "text", "text": "上海呢" }] }
      ]
    }
  ],
  "nextPageToken": "older"
}
字段必填说明
tasks是任务,从早到晚。包在 JSON-RPC 响应的 result 里也认
tasks[].id是任务编号,就是轮次编号
tasks[].status.state是见下表
tasks[].status.message否失败时,里面的文字是失败原因
tasks[].status.timestamp否最后一次状态变化的时间(ISO 8601)
tasks[].history否这一轮的用户消息(role: user)和 Agent 的过程消息(role: agent),按先后顺序
tasks[].artifacts否这一轮的产出
tasks[].metadata.lastEventId否最后一个事件的编号。还在进行的任务要续接就必须给
nextPageToken否取更早一页用的游标;没有就是没有更早的内容了
status.state这一轮的结果
completed完成
failed、rejected失败
canceled、input-required、auth-required中断
submitted、working还在进行

服务端是 ADK 执行器(正文在 status-update 里流式下发)时,接入方会声明 profile: 'adk',历史的格式不变: 过程消息里是正文和工具调用,最后一条产出是最后一条过程消息的副本。

已知限制

#限制说明
1前端加工出来的内容,历史里要有同样的数据才能在刷新后还在接入方可以用 transformOutput 从流里的事件多取一些内容显示出来(例如引用来源的卡片)。历史同样会经过这个函数,前提是历史里带着同样的数据,见下表
2OpenAI 兼容接口:工具在浏览器里执行时,刷新后的续接接不上工具循环一轮里发给模型的上下文只在页面内存里,刷新后就没了。续接回来的流如果要求执行浏览器里的工具,这一轮以失败结束。页面没刷新时的断线续接不受影响
3OpenAI 兼容接口:不认轮次编号时无法定位远端操作SDK 的远端停止、续接与历史需要额外的身份和存储约定;无状态后端仍可用 run 实现重新生成/编辑,刷新后无历史则清空
4ADK:当前历史解析不生成轮次终态快照历史里的轮次不能重新生成、不能编辑后重发。是否仍在运行需要明确的 active_run
5Hermes:消息上没有 run_id 时,历史和刚跑完的内容靠顺序对应同一个会话有别的客户端同时写入时,对应关系可能错位,界面会在刷新后按后端的历史重排。带上 run_id 就没有这个问题
6A2A:任务快照里没有过程消息和产出之间的先后历史里过程消息在前、产出在后。一轮里正文和工具调用交替出现时,刷新后的顺序可能和当时看到的不一样。消息没有时间,历史里的消息不显示时间
7A2A:游标来自 SSE id 或序列外层 eventId协议的重新订阅不带位置,后端要按约定认前端给的事件编号,只发它之后的事件。不认的话续接回来的内容可能和已有的重复
8停止需要终态确认点击停止后仍接收输出;只有明确终态才结束运行。终态之后的迟到输出被拒绝
9翻页只支持往更早的方向历史的第一页是最新的内容,游标用来取更早的一页

第 1 条里,各协议的历史要带什么:

协议历史里要带什么
OpenAI 兼容接口流块顶层的业务字段,原样放在历史里对应的助手消息上(同名、同结构)
ADK事件原样存(历史本来就是事件)
Hermes业务字段放在历史里对应的消息上。接入方收到的事件名是 history.message,不是流里的事件名,要按这个名字另外处理
A2A过程消息和产出原样存在任务里

哪些行为要对着真实后端确认

以下是示例依赖的部署假设,并未对真实服务逐一验证,不能作为官方协议保证。接入时需要确认:

行为怎么确认
ADK 服务对不存在的会话返回 404用一个不存在的会话编号发一次 run_sse,看状态码
ADK 服务可以用指定的编号新建会话对 .../sessions/{编号} 发一次新建请求,再取这个会话,看编号是不是指定的那个
Hermes 对不存在的会话返回 404 加 session_not_found用一个不存在的会话编号发起一次对话,看状态码和响应内容
Hermes 的每个事件都带 seq发起一轮,看流里每个事件的 data 里有没有 seq。没有的话要在 SSE 的 id: 里带事件编号
Hermes 会话列表接口的返回形状请求一次会话列表,对照上文「Hermes · 会话」里认的形状和字段
A2A 的 message/send 带 taskId 可以给进行中的任务追加消息在一轮进行中发一次带 taskId 的 message/send,看响应里有没有 error,以及这一轮有没有收到内容

会话状态与持续接收

历史可附带 session.status(idle / running / error / unknown),状态缺失或无法映射时为 unknown,私有值保留在 metadata.backendStatus。active_run 定位仍在执行的轮次及游标。两者不能互相替代;状态在独立会话接口时,由 history 函数组合响应。

独立接收使用 subscribe:运行命令返回受理,事件通道提供输出。开始和结束事件都要能定位 runId。HTTP、分离接口、WS 与重放顺序见发送和接收。