返回博客

一个实例聊了上百轮全混在一起?怡途Claw 会话管理 API 多会话隔离与历史回看实战

2026年7月29日阅读约 25 分钟
怡途Claw 会话管理 API 多会话隔离实战

一个实例默认把所有对话堆进同一条 main 会话,上下文越滚越长、答非所问。怡途Claw 的 /api/app/chat/instances/:id/sessions 一组接口用 sessionId 给消息分桶,这篇把列会话、建会话、发消息带 session、回看历史、删会话串成一条线,每个请求带真实字段和踩坑点。

一个实例跑起来、能流式对话之后,最容易踩的下一个坑是会话管理。默认情况下,你每次 POST /api/app/chat/instances/:id/messages 不传 sessionId,所有消息全落进同一个叫 main 的桶里——第一轮问部署、第二轮问计费、第三轮让它写代码,上下文一整条线滚下去,agent 记着三天前的废话,token 烧得快、答非所问。怡途Claw 这组 /sessions 接口就是用来给消息分桶的,这篇文章把"列会话 -> 建会话 -> 发消息带 session -> 回看历史 -> 删会话"串成一条线,每个请求都带真实字段和踩坑点。

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

一、先搞清楚:会话不是一张表,是消息上的一个标签

很多人以为"建会话"是往一张 sessions 表里插一行,其实不是。怡途Claw 里没有独立的会话表——会话是从消息的 metadata.sessionId 字段还原出来的。每条消息带着自己属于哪个 sessionId,默认值 mainGET /sessions 接口干的事,就是扫最近一批消息、按 sessionId 聚合,临时拼出一个会话列表还给你。

理解这一点,后面几个反直觉的行为就都解释得通了:会话不是预先存在的实体,而是"有消息才有会话"。

二、列会话:GET /sessions

GET /api/app/chat/instances/:instanceId/sessions?limit=20
Authorization: Bearer <token>
{
  "success": true,
  "data": [
    {
      "sessionId": "9c43a6f5-b432-4d5f-a17e-9f0dfb5c464a",
      "title": "帮我查本月用量最高的实例",
      "lastMessageAt": "2026-07-29T01:12:33.000Z",
      "preview": "帮我查本月用量最高的实例",
      "messageCount": 8
    },
    {
      "sessionId": "main",
      "title": "主会话",
      "lastMessageAt": "2026-07-28T09:40:11.000Z",
      "preview": "这个实例怎么切换模型",
      "messageCount": 23
    }
  ]
}

每条返回里 messageCount 是这个会话的消息数,preview 是最新一条消息的正文截取,title 的取值优先级是:消息里显式带的 sessionTitle > 第一条用户消息正文前 30 个字 > 兜底的 新会话。前端拿这个数组直接渲染侧边栏就行。

踩坑一:列表只扫最近约 200 条消息重建。 listSessions 内部是 take: max(limit * 20, 200) 条消息来聚合,不是全量扫表。如果一个老会话最近 200 条消息里一条都没有,它在列表里就"消失"了——消息没丢,只是没被扫到。想要老会话稳定可见,要么控制实例上的会话数量别堆太多,要么按 lastMessageAt 做翻页,别指望一次拉全。

踩坑二:全新实例返回一个假的 main 一个消息都没有的实例,接口不会返回空数组,而是塞一条 sessionId: "main", title: "主会话", messageCount: 0 的占位。前端判断"有没有会话"别用 data.length > 0,要用 messageCount > 0,否则新实例永远显示有一条空会话。

三、建会话:POST /sessions——它只是发了个 UUID

POST /api/app/chat/instances/:instanceId/sessions
Content-Type: application/json
Authorization: Bearer <token>
{ "title": "排查计费异常" }
{
  "success": true,
  "data": {
    "sessionId": "7a1e3c64-9b22-4f01-8e6d-2c5b8f3a1d77",
    "title": "排查计费异常",
    "createdAt": "2026-07-29T02:00:48.000Z"
  }
}

这是最容易误解的接口:POST /sessions 不落库、不建任何记录,它只是用 randomUUID() 现场生成一个 sessionId 还给你。 服务端此刻对这个 id 一无所知。

这意味着你调完 POST /sessions 立刻去 GET /sessions,列表里不会出现这个新会话——因为它还没消息。这个 sessionId 要等你拿它去 POST /messages 发了第一条消息,才真正"活"起来、出现在列表里。所以正确用法是:前端拿到返回的 sessionId 存进本地状态,把它作为后续发消息、接流、回看历史的 sessionId 参数,第一次发完消息它自然进列表。标题不传时兜底 新会话,发首条用户消息后会自动用消息正文前 30 字覆盖。

四、发消息带 sessionId:会话这才活起来

POST /api/app/chat/instances/:instanceId/messages
Content-Type: application/json
Authorization: Bearer <token>
{
  "content": "本月计费突然翻倍,帮我查是哪个实例涨的",
  "sessionId": "7a1e3c64-9b22-4f01-8e6d-2c5b8f3a1d77",
  "sessionTitle": "排查计费异常",
  "waitForReply": false
}

sessionId 传第三步拿到的那个;不传默认 main,空白字符串也会被归一成 main,别传 "" 以为能新建一个空名会话。sessionTitle 想让侧边栏显示得好看就带上,会和消息一起落进 metadataGET /sessions 取标题时优先读它。waitForReplyfalse——给 true 会让 HTTP 请求挂着等 agent 跑完,长任务几十秒起步网关早超时,正确做法是发完拿 streamUrl 去接 SSE 流(流式对话那篇讲过,这里不重复)。

返回里 streamUrl 已经把 sessionIdafterEventSeq 带好了:

{
  "success": true,
  "data": {
    "sessionId": "7a1e3c64-9b22-4f01-8e6d-2c5b8f3a1d77",
    "pending": true,
    "runId": "b3f2c1a8-...",
    "userMessage": { "id": "..." },
    "streamUrl": "/api/app/chat/instances/<id>/stream?sessionId=7a1e3c64-...&afterEventSeq=42"
  }
}

接流时 GET .../stream?sessionId=同一个id,游标和会话都对上,agent 的回复才会进对桶。sessionId 传错或漏传,回复就串到 main 去了,这是多会话场景下最常见的"消息对不上"根因。

五、回看历史:GET /messages 带游标翻页

GET /api/app/chat/instances/:instanceId/messages?sessionId=7a1e3c64-...&limit=50
Authorization: Bearer <token>
{
  "success": true,
  "data": {
    "messages": [
      { "id": "...", "role": "user", "content": "..." },
      { "role": "assistant", "content": "..." }
    ],
    "hasMore": false,
    "sessionId": "7a1e3c64-9b22-4f01-8e6d-2c5b8f3a1d77"
  }
}

sessionId 不传就是 main 的历史。limit 默认 50,hasMoretrue 时用 before 参数翻页——传上一页最早那条消息的 id 当游标,往更早的方向拉。别一次性把 limit 调到几千,历史长时按游标翻页比硬拉稳。

六、删会话:DELETE /sessions/:sessionId

DELETE /api/app/chat/instances/:instanceId/sessions/7a1e3c64-9b22-4f01-8e6d-2c5b8f3a1d77
Authorization: Bearer <token>
{ "success": true, "message": "会话已清空" }

删的是这个 sessionId 名下的所有消息,会话本身不存在所以也没什么"删记录"一说——消息没了,下次 GET /sessions 自然不再聚合出它。sessionId 是个普通字符串,删完可以复用同一个 id 重新发消息,不冲突。

踩坑:别用空 sessionId 调删除。 服务端会把空值归一成 mainDELETE .../sessions/ 这种路径或传空,实际删的是主会话 main 的全部消息,不是"当前会话"。删之前确认 path 里的 sessionId 是你真正想删的那个,尤其是前端从输入框拿 id 时要做非空校验。

收尾

把这几步连起来就是一套完整的多会话能力:POST /sessions 拿一个 UUID 存本地 -> 每次发消息、接流、回看历史都带上它 -> 用 GET /sessions 渲染侧边栏(注意 200 条扫描窗和 messageCount 判空)-> 不要的会话 DELETE 掉。配合部署、计费、流式对话那几篇,一个能跑、能算钱、能多轮、还能分桶管理的 agent 接入链路就齐了。核心就一句:会话是消息上的标签,不是独立实体,发消息带对 sessionId 比什么都重要。