OpenDots 负责把一句话拆成可执行的任务、调度步骤、收敛执行状态;CopilotKit 负责界面上的对话、消息渲染和用户操作。两边不重叠的部分是:任务树、步骤状态、执行结果归 OpenDots;消息历史、会话上下文、用户输入归 CopilotKit。真正容易出问题的是接口层:如果两边对 session 标识、任务标识、结果字段各有一套命名,界面就会显示“请求发出去了但状态不动”。判断标准很简单——同一次交互能否在两边日志里用同一个 id 串起来,串不起来就先别加功能。
如果前端已经用 CopilotKit,加入 OpenDots 后建议只让它接管编排:任务拆分、步骤调度、执行状态与结果。会话与消息历史仍由交互层持有,OpenDots 侧保存 session_id 作为外键即可,不另存一份对话。对接先做三件事:确定请求、任务、结果三类字段的流向;给每个字段定唯一持有方;用一次真实请求在调用入口上联调。字段名不一致时先写映射表再改代码,改名后必须重放同一条请求复验。适用边界是单前端、单编排服务的接入场景,多端共用会话时需要另行确认 id 生成规则。
画出一次交互的字段流向
先别改代码,先在纸上确认一次交互经过哪几个节点。典型链路是四个节点:浏览器界面 → CopilotKit 的 runtime/后端接入点 → OpenDots 的调用入口 → OpenDots 的调度与执行器。职责建议按下表切开,切完之后再谈字段。
| 职责 | 建议归属 | 说明 |
|---|---|---|
| 用户输入、消息渲染、历史滚动 | CopilotKit 一侧 | 界面直接消费,放编排层会多一次往返 |
| 任务拆分、步骤顺序、重试与超时 | OpenDots | 属于编排逻辑,交互层不应判断 |
| 会话 id 的生成 | 交互层生成,编排层只保存 | 两边都生成就会出现两套 id |
| 任务状态(pending/running/done/failed) | OpenDots | 唯一真值来源 |
| 最终回复文本 | OpenDots 产出,CopilotKit 渲染 | 交互层只做展示与格式化 |
三类数据在层间的走向可以这样记:
- 请求:界面把用户这一轮的 message、session_id、必要的 client_meta 交给 CopilotKit 接入点;接入点补齐或校验 session_id 后,转成 OpenDots 调用入口要求的入参。
- 任务:OpenDots 收到请求后生成 task_id,把 message 拆成步骤,返回 task_id 和初始状态;此后状态只在 OpenDots 内部变化,交互层通过查询或事件拿快照。
- 结果:任务结束时 OpenDots 返回结果对象(状态、输出文本、错误码),交互层把输出文本渲染成一条消息,把状态更新到对应的 task_id 上。
画完这张流向图,如果发现某个字段同时出现在两边并且都可能被写,那就是后面状态分叉的源头,先标出来。
确定消息与会话状态由哪一层持有
最省事的做法不是“两边同步”,而是选一个持有方,另一边只读。选择依据有三条:谁直接渲染谁持有、谁需要事务谁持有、谁能独立重启谁持有。按这三条,消息历史与对话上下文建议由 CopilotKit 一侧持有,因为界面要直接渲染并且需要分页加载;任务与执行状态由 OpenDots 持有,因为重试、超时、步骤顺序只有编排层清楚。
落到约定上:交互层保存 session_id 和消息列表,把 session_id 作为外键传给 OpenDots;OpenDots 只保存 session_id 与 task_id 的关联,不留一份完整对话副本。反过来,交互层对 task 状态只读,不做乐观改写,界面上的“执行中”以 OpenDots 返回的状态为准,超时未更新时显示为未知而不是自行猜测成功。
验证方式很直接:重启其中一侧,看另一侧是否还能凭 id 恢复。如果重启 OpenDots 后界面上的历史消息还在、任务状态变成未知并可重新查询,说明持有方划分是成立的。
在编排层暴露的调用入口上做一次联调
不要等前端页面全做完再联调。用一个最简的 HTTP 调用先把入口打通,字段全部占位,能返回 task_id 就算通过。下面是一个通用骨架,路径与字段名需要按实际 OpenDots 入口替换:
curl -X POST http://localhost:PORT/opendots/tasks \
-H 'Content-Type: application/json' \
-d '{
"session_id": "sess_demo_001",
"task_id": null,
"message": "把这份数据整理成三段摘要",
"client_meta": {"source": "copilotkit", "locale": "zh-CN"},
"timeout_ms": 30000
}'
预期的返回骨架,字段同样是占位:
{
"task_id": "task_xxx",
"status": "pending",
"session_id": "sess_demo_001",
"result": null,
"error": null
}
联调时盯三个点:返回里必须带回同一个 session_id,便于两边日志对齐;task_id 必须非空,否则后续状态查不到;status 的取值集合要提前固定,交互层只认这几个值。用这条 curl 跑通之后,再把 CopilotKit 接入点的一侧替换成真实请求,改动面就只剩字段映射。
两边字段名不一致时的对齐做法
对接最常见的卡点就是同一个东西三个名字:界面叫 conversationId,接入点叫 session_id,编排层叫 sessionId。别急着全局替换,先写一张映射表放在接入层代码旁边,作为唯一改名入口。
| 语义 | 交互层字段 | 编排层字段 | 转换位置 |
|---|---|---|---|
| 会话标识 | conversationId | session_id | 接入层请求构造 |
| 本轮用户输入 | message.content | message | 接入层请求构造 |
| 任务标识 | runId | task_id | 接入层响应解析 |
| 执行状态 | runStatus | status | 接入层响应解析 |
| 结果文本 | output | result.text | 接入层响应解析 |
映射表要写清三列之外的两件事:空值怎么表示(null 还是缺字段),以及枚举值的对应关系(比如 success 对 done)。改名后的复验步骤建议固定成四步:第一步,重放联调时那条 curl,确认返回未变;第二步,在前端发同一句输入,比对两边日志里的 session_id 与 task_id 是否一致;第三步,检查界面渲染的文本是否等于编排层 result.text 的原文;第四步,构造一次空结果,确认空值约定被两边同样处理。四步都过再提交,不要跳过第三步,文本被二次加工的问题只能在这一步暴露。
失败时先看哪一层的返回
排查要有确定的起点,按返回来源分成三类,动作各不相同。
- 交互层返回的失败:表现为界面报错、请求未到达编排层,或接入点返回 4xx。下一步动作是看浏览器网络面板和接入点日志,确认请求有没有发出、header 与序列化是否正常,先不动 OpenDots。
- 编排层返回的失败:拿到 task_id 但 status 变成 failed,或返回体里 error 非空。下一步动作是去 OpenDots 侧按 task_id 查任务详情与步骤日志,定位是拆分阶段失败还是某一步执行失败,同时确认超时时间是否过短。
- 回传解析阶段的失败:接口返回 200,但界面显示空白或一直停在执行中。这类多半是字段名或枚举没对上,下一步动作是对照映射表逐项核对响应字段,尤其是 status 的取值和 result 的嵌套路径。
三类失败的区分标志是有没有 task_id:没有 task_id 基本在前两层之间,有 task_id 就往编排层和回传解析上找。把这条判断写进排查记录,后续接手的人不用每次从头猜。