Blog posts and course content are currently in Chinese. Translation is in progress.
Back to Blog

AI Agent 部署完怎么对话?怡途Claw 流式对话 API 发消息到实时打字实战

2026年7月26日24 min read
AI Agent 部署完怎么对话?怡途Claw 流式对话 API 实战

部署好 AI Agent 只是第一步,真正难的是把对话接进自家 App。本文用怡途Claw 的 /api/app/chat/* 接口,把发消息、SSE 长连接、实时打字事件、工具调用卡片、移动端 slim 流量优化串成一条线,附 delta 累计全文坑和断线重连。

部署好一个 AI Agent,控制台里能跑能答,离"接进自家 App 让用户用上"还差着最硬的一段路:对话怎么调。不少团队第一反应是轮询——发完消息每秒 GET 一次拿结果,能跑起来,但 agent 一边思考一边吐字的过程全丢了,用户看到的是"转圈 30 秒、啪一下整段蹦出来",体验和 ChatGPT 差一个时代。怡途Claw 的 /api/app/chat/* 这组接口默认就是 SSE 长连接,这篇文章把"发消息 -> 接流 -> 解析实时打字 -> 渲染工具调用 -> 移动端省流量 -> 断线重连和取消"串成一条线,每个请求都带真实字段和踩坑点。

所有接口都要 JWT,放 Authorization: Bearer <token>,和创建实例、查计费是同一套登录态。下面假设你已经照着部署那篇建好实例、拿到 instanceId 和 token。

一、发消息:POST /messages

POST /api/app/chat/instances/:instanceId/messages
Content-Type: application/json
Authorization: Bearer <token>
{
  "content": "帮我查一下本月用量最高的实例",
  "sessionId": "main",
  "waitForReply": false
}

sessionId 不传默认 main,多轮对话用不同 id 隔离历史。waitForReplyfalse 就行——给 true 会让这个 HTTP 请求一直挂着等 agent 跑完才返回,长任务几十秒起步,网关早超时了,正确做法是发完立刻拿 streamUrl 去接流。响应长这样:

{
  "success": true,
  "data": {
    "sessionId": "main",
    "pending": true,
    "runId": "9c43a6f5-b432-4d5f-a17e-9f0dfb5c464a",
    "userMessage": { "id": "example-user-message" },
    "assistantMessage": null,
    "streamUrl": "/api/app/chat/instances/<instanceId>/stream?sessionId=main&afterEventSeq=31"
  }
}

runId 是这一次对话的执行 id,后面要取消就靠它。streamUrl 里已经把 afterEventSeq=31 带好了,直接拼上 host 去连,别自己从头算游标。

二、接流:这是 SSE 长连接,不是轮询

GET /api/app/chat/instances/:instanceId/stream?sessionId=main&slim=1
Accept: text/event-stream
Authorization: Bearer <token>

从 2026-07 起,这个接口的默认响应格式就是 clawpilot SSE(text/event-stream,header 带 X-Accel-Buffering: no 禁掉 Nginx 缓冲),不用传任何参数。App 端要用 SSE 客户端消费,不是每秒 GET 一次的轮询。老的 JSON 轮询格式得显式传 ?format=legacy 才走,每次还把最近 100 条消息全量重传一遍,移动端流量重、渲染慢,新接入别再用了。

afterEventSeq 是断线重连的游标:首次连接传 0,服务端从当前 run 开始回放;断线重连时传上一次收到的最大 seq,不会漏事件。想压流量就加 ?slim=1(见第五节),但得先把客户端适配好再开,不然会看到文字乱序。

三、chat 事件:一个状态机,delta 最容易踩坑

SSE 帧长这样:

event: chat
data: { 一段 JSON }

event: chat 是主对话流,靠 data.state 区分阶段:

state触发App 该做
started一次 run 开始新建回复气泡,显示 loading
deltaagent 在实时打字更新气泡文本(见下面坑)
finalrun 成功结束用 final 内容覆盖气泡,停 loading
aborted被中断标"已中断",停 loading
error运行失败显示错误,停 loading

delta 这条有三个坑,全踩过:

  1. data.message.content[0].text 默认是累计全文,不是增量。App 直接 bubble.text = text 替换,别 +=,否则同一段话会重复拼好几遍。只有开了 ?slim=1 且这帧带 patch:true 时,text 才是新增片段,这时候才用 +=
  2. delta 可能只带 loading:true、没有 message 字段(agent 还在思考、没开始吐字)。直接去取 message.content[0] 会崩,先判空。
  3. message.content 是数组final 里会混 type:'text'type:'tool_call' 两种块。取正文要按 type 过滤,别无脑取 [0]

final 到了就别再用 delta 的文本了——final 是定型的完整快照,拿它覆盖一次,能修掉 delta 过程里偶尔的乱序。

四、agent 事件:思考过程和工具调用走这条侧信道

光有正文还不够,agent 真正在干活(思考、调工具、拿结果)的过程走的是 event: agent

streamphase内容App 该做
assistant-reasoning / 思考塞进气泡下的"思考中"折叠区
toolstartedtoolCallId / name / args建 tool 卡片,标 running
toolresulttoolCallId / result按 toolCallId 找到卡片,写结果,标完成

工具卡片用 toolCallId 配对 started 和 result,别用 name——同一个 run 里 agent 可能调两次 bash,name 一样但 id 不同。assistant 流里的 data.text 是思考过程的灰字,和正文分开渲染,别混到主回复里。

这套事件齐了,App 端才能复刻 openclaw 那种"思考中 -> 正在执行 bash -> 拿到结果 -> 继续打字"的过程,而不是干等一个 final。

五、slim=1:长回答和工具输出别把流量放成 O(N²)

默认协议下,delta 每次推的都是自开头累计的全文——回答越长,后面每一帧的体积越大,整体流量是 O(N²)。一篇两千字的回答,到尾巴上每帧都在重传前面一千多字。工具输出更夸张:Bash / Read / Grep 动辄几十到几百 KB,一帧塞一个巨型 JSON,手机端 JSON.parse 直接卡主线程掉帧。

?slim=1 后语义变(其他事件不变):

  • deltapatch:true,text 变成真增量,accumulated += text 拼接。长回答流量从 O(N²) 降到 O(N),压测收益 80% 以上。
  • 单个 tool 的 argsresult 超 4KB 时,不再一帧塞完,改成 args_chunk / result_chunk 分片,每片配 chunkFinal。App 按 toolCallId 累积,chunkFinal:trueJSON.parse。200KB 的 grep 输出拆成约 50 个 4KB 小帧,边收边渲染,不再卡死。

坑写在前面:先把 slim 的新字段适配好再传 ?slim=1。没适配就开,会直接看到文字乱序、工具结果不显示——这不是后端的 bug,是客户端漏了 patch:true 和分片分支。

六、断线重连和中途取消

断线了别从头来,带上一次的最大 seq 重连:

GET /api/app/chat/instances/:instanceId/stream?sessionId=main&afterEventSeq=38&slim=1
Authorization: Bearer <token>

注意 seq 不保证连续——可能从 31 直接跳到 38,中间的号是服务端内部事件或被过滤掉的,不算丢事件。只要 lastEventSeq 往前走了,本地游标就跟着更新,哪怕这帧 events 是空的。

要中断正在跑的 run:

DELETE /api/app/chat/instances/:instanceId/runs/:runId
Authorization: Bearer <token>

runId 就是发消息时返回那个。删成功回 {"success":true,"message":"已取消运行"};run 已经结束了会回 404 没有正在运行的请求或已结束,App 端把这俩都当"已结束"处理就行,别把 404 当真错误弹给用户。

小结

把这条线接通,App 端就能像 ChatGPT 一样看到 agent 实时打字和工具执行:POST /messages 发消息拿 streamUrlGET /stream 当 SSE 长连接消费、event:chatdelta 驱动打字(默认替换、slim 下累加)、event:agent 渲染思考和工具卡片、移动端开 ?slim=1 省流量、afterEventSeq 断线重连、DELETE /runs/:runId 中途取消。接入时十有八九会踩的同一个坑:客户端漏了 delta 分支,于是用户看不到实时打字、干等一个 final 蹦出来——对照第三节那三个坑逐条查,基本就通了。