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

测试数据想删一条删不掉?怡途Claw 单条消息删除与会话统计 API 实战

2026年8月6日36 min read
测试数据想删一条删不掉?怡途Claw 单条消息删除与会话统计 API 实战

聊了 200 条想把第 137 条测试数据删了别让 AI 学偏?DELETE /api/app/chat/instances/:id/messages/:messageId + GET /stats 一次性把单条硬删、统计四件套讲清,连 system 错误消息、tokenCount 是否真有数、5xx 异常翻译 5 个坑都给到。

删一条测试消息、调一份会话体检:怡途Claw 单条消息删除与会话统计 API 实战

聊到 200 轮的时候突然想:第 137 轮那条手滑发的「/test abc」是不是把 AI 带歪了?想删掉它,但 DELETE /api/app/chat/instances/:id/sessions/:sessionId 一刀下去整段历史全没,没法精准。

或者反过来:你是产品经理,想给老板交一份「这个实例聊了多少轮、花了多少 token」的月报,对着 200 条消息手工数不现实。

这一篇就解决这两件事:精准单条删除 + 单实例会话统计,两条端点、5+4 个常见踩坑,给到能直接 curl 的版本。


一、先看官方列的两个端点到底能干嘛

来源文件:src/app-api/chat/app-chat.controller.ts:425(删除)与 :439(统计)。

端点作用返回
DELETE /api/app/chat/instances/:id/messages/:messageId单条消息硬删{ success, message: '消息已删除' }
GET /api/app/chat/instances/:id/stats单实例会话四件套{ success, data: { totalMessages, userMessages, assistantMessages, totalTokens } }

鉴权两条都走类级 JwtAuthGuard,传 Authorization: Bearer <token> 即可,token 拿法见 307 那篇登录鉴权。

跟 305 那篇会话管理的边界:DELETE /sessions/:sessionId 是清整段会话;DELETE /messages/:messageId 是清一条消息。前者适合「这个分支不想要了」,后者适合「这一句话发错了」。


二、精准删除:DELETE /messages/:messageId

curl 模板:

curl -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "https://www.yitukeji.cn/api/app/chat/instances/$INSTANCE_ID/messages/$MESSAGE_ID"

返回:

{ "success": true, "message": "消息已删除" }

要拿 MESSAGE_ID 有两条路:

  • GET /messages?sessionId=xxx 列表里挑(305 那篇讲过)
  • 从 SSE stream event: chatdata.message.id 拿(302 讲过)

删完以后,服务端干三件事(src/chat/chat.service.ts:999-1016):

  1. 真删messageRepository.remove(message),是 DELETE 不是软删,DB 行没了。
  2. 清运行时缓存clearRuntimeSessionIdCache(instanceId, userId, sessionId),把这一个会话的运行时 session 缓存键删掉。下次再发消息时这个 session 会被重新建,副作用=已签发的 access_session token 里指向的会话元数据会被刷新。
  3. 广播 message.deleted 信号chatRunEventsService.notifyMessageChange(...) 推一条 { reason: 'message.deleted' } 到当前 SSE stream,客户端长连接能感知到。

坑 1:删 user 消息会让 SSE stream 把这条消息「彻底消失」

实现里把整条 row 删了,不是软删 isDeleted=true。所以列表接口和 stream 都看不到这条了,不是「已撤回」也不是「已隐藏」,是「从数据库里没了」。如果你的 App UI 想做「撤销」按钮并保留 UI 留痕,要自己用 status='deleted' 改模型加软删字段,目前 API 没暴露。

坑 2:删 assistant 消息会破坏配对

前端如果要展示「用户问 / AI 答」配对卡片,删了一条 assistant 后,可能留下一条孤零零的 user 消息没下文。

实现不会自动联动删 parentId 指向它的兄弟,UI 层最好按 (user.parentId, assistant.parentId) 配对渲染。

坑 3:删一条不在你这个实例 / 不是你的用户的消息 → 500 不是 404

实现里 findOne({ id, instanceId, userId }) 找不到时直接 throw new Error('Message not found')src/chat/chat.service.ts:1004),没捕获。NestJS 默认会把裸 Error 翻 500,不是 404

实操要点:

  • 删之前GET /messages?sessionId=xxx 确认这条消息的 instanceId 跟当前实例 ID 一致
  • 跨实例 / 跨用户的消息 ID 传进来,会得到 { statusCode: 500, message: 'Message not found' }
  • 客户端要按 500 兜底,或者提前校验 messageId 是不是当前 session 列表里的合法值

坑 4:删完再发新消息会重建 sessionId 缓存

实现里 clearRuntimeSessionIdCache 把该 session 缓存键删了。下次发消息时,runtime(比如 claude-code、codex)的 session id 会重新建一次。如果你自己客户端有缓存「这条消息对应的 runtime session id」,删完要清掉,否则下次 POST /messages 可能因为 stale cache 出现不期望的会话延续。

坑 5:删 system 错误消息(status='error')也行

Message 实体的 role 字段是 'user' | 'assistant' | 'system'src/chat/message.entity.ts:18),DELETE 端点不挑 role,删 user/assistant/system 都行。

只有在客户端有该 message id 的时候才能删。SSE stream event: chatdata.message.id 对所有 role 都暴露,包括 status='error' 的 system 行,UI 上加个「移除这条错误」按钮是可行的。


三、统计四件套:GET /stats

curl:

curl -H "Authorization: Bearer $TOKEN" \
  "https://www.yitukeji.cn/api/app/chat/instances/$INSTANCE_ID/stats"

返回:

{
  "success": true,
  "data": {
    "totalMessages": 237,
    "userMessages": 119,
    "assistantMessages": 117,
    "totalTokens": 184320
  }
}

四个字段含义(src/chat/chat.service.ts:1053-1069):

字段含义来源
totalMessages全部消息条数(含 system)messages.length
userMessages用户发的条数filter(role === 'user')
assistantMessagesAI 回复的条数filter(role === 'assistant')
totalTokens所有消息的 tokenCount 求和sum(m.tokenCount || 0)

四、stats 的 4 个真实坑

坑 1:totalMessages - userMessages - assistantMessages = system 消息数

注意 stats 没有单独的 systemMessages 字段。要算「出错多少次」只能 totalMessages - userMessages - assistantMessages

system 消息在 src/chat/chat.service.ts:707-727 里只在 LLM 调用失败时插入一条(role: 'system', content: 'Error: ...', status: 'error'),所以这个差值近似等于错误次数

  • 实现是「先 save system 消息再 throw 原 error」(src/chat/chat.service.ts:727-734),所以即使前端拿到 500,DB 里 system 消息已经在了
  • 唯一会破坏这个等式的情况:极少数边界场景下,异常抛在 messageRepository.save(errorMessage) 之前(比如 DB 连接断了),那时候 system 写库失败但实例仍记了一次失败 → stats 差值 < 真实错误次数

坑 2:tokenCount 经常是 0,不要拿来做计费

实现里 tokenCount 来自两个源(src/chat/chat.service.ts:280-283, 667):

  1. assistant 消息从 LLM 响应里 response.tokenCount 直接落库(写库时直接用,没去重没去空)
  2. 其它场景从 result.metadata?.tokenCount 读,没有就 0

真实计费走的是另一条链路src/chat/token-usage-interceptor.service.ts + 301 那篇讲过的 token_usage 表(不是 messages.tokenCount)。所以:

  • GET /stats 拿到的 totalTokens 只是 assistant 消息落库时的标记值,跟账单对不上、跟 301 /api/app/billing/token-usage 也不对得上
  • 月报场景如果要做 token 汇总,用 301 的 /token-usage 端点,不要用这个 stats

实操:把 GET /stats 定位成「聊了多少轮的体检」,token 当参考别当真。

坑 3:stats 是「全量 find」+「JS 内存 filter」,大实例会卡

实现里 getMessageStats 直接 messageRepository.find({ instanceId, userId })src/chat/chat.service.ts:1059),把所有字段全拉进 Node 内存再 filter/reduce。

如果一个实例聊了 1 万条 + 内容很长(content TEXT),一次请求会:

  • 拉 1 万行全字段(含 content 大文本)
  • Node 内存里 filter 2 次 + reduce 1 次
  • 返回的 data 只有 4 个数字,但你为此付的内存和带宽是全量 content

前端按 5-30 秒间隔轮询大实例的 stats,会拖累后端 DB。实操建议:

  • 月报场景用 301 的 /token-usage SQL 聚合,别轮询 stats
  • 如果非用 stats,给它加缓存(@CacheKey + @CacheTTL(60))或前端限制调用频率
  • 自己改实现的话,把 find 换成 count/sum/QueryBuilder,DB 端聚合只回 4 个数字

坑 4:跨 session 不区分

stats 是按 (instanceId, userId) 过滤的,不区分 sessionId。也就是说一个实例下开了 5 个会话,总数是 5 个会话加一起的体检。

要看单 session 统计,目前没有现成端点,要自己从 GET /sessions 拿 sessionId,再 GET /messages?sessionId=xxx 列表然后前端 filter role / 求和 tokenCount。


五、删除 + 统计的组合用法

实操里这两个端点经常一起用:

  1. GET /stats 看总条数,判断要不要清理
  2. GET /messages?sessionId=xxx 拉要删的那条 messageId
  3. DELETE /messages/:messageId 单条删(注意:跨实例/跨用户会返 500,按 500 兜底)
  4. GET /stats 验总数对不对

伪代码:

// 1. 看一眼现在多少条
const stats = await getStats(instanceId);
console.log(`当前 ${stats.totalMessages} 条,${stats.totalTokens} token`);

// 2. 拿到要删的消息 id 列表(自己挑,或者业务规则筛)
const toDelete = ['msg-uuid-1', 'msg-uuid-2'];

// 3. 逐条删
for (const id of toDelete) {
  try {
    await deleteMessage(instanceId, id);
  } catch (e) {
    console.warn(`删除 ${id} 失败:`, e.message);
    // 注意:5xx 也算失败,按 500 兜底
  }
}

// 4. 验
const after = await getStats(instanceId);
console.log(`删后 ${after.totalMessages} 条`);

六、和其它端点的边界

场景用哪个
整段会话都不要了DELETE /sessions/:sessionId(305)
单条消息错了DELETE /messages/:messageId(本篇)
当前消息跑飞了要中止DELETE /runs/:runId(302)
整实例对话全清clearConversation(instanceId)(走 chat.controller 的 legacy 端点,新 app-chat 没单独暴露)
单实例统计GET /stats(本篇)
真实账单 token 汇总GET /billing/token-usage(301)
媒体生图生视频消耗GET /billing/media-usage(310)

小结

单条删除和单实例统计,名字看着像管理后台功能,实际上是 App 端常用高频端点:

  • DELETE /messages/:messageId 适合做「撤销最后一条」「移除错误消息」按钮,但要知道它是真删、5xx 兜底、SSE 推 message.deleted 信号。
  • GET /stats 适合做「实例健康卡片」「聊了多少轮」展示,但别拿 totalTokens 当计费,那是另一条链路。

下一篇会展开 SSE 在大回答下的内存压力点和分片恢复策略的实操细节,跟本篇的 stats 性能坑位是一组。