聊了 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: chat的data.message.id拿(302 讲过)
删完以后,服务端干三件事(src/chat/chat.service.ts:999-1016):
- 真删:
messageRepository.remove(message),是DELETE不是软删,DB 行没了。 - 清运行时缓存:
clearRuntimeSessionIdCache(instanceId, userId, sessionId),把这一个会话的运行时 session 缓存键删掉。下次再发消息时这个 session 会被重新建,副作用=已签发的access_sessiontoken 里指向的会话元数据会被刷新。 - 广播
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: chat 的 data.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') |
assistantMessages | AI 回复的条数 | 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):
- assistant 消息从 LLM 响应里
response.tokenCount直接落库(写库时直接用,没去重没去空) - 其它场景从
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-usageSQL 聚合,别轮询 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。
五、删除 + 统计的组合用法
实操里这两个端点经常一起用:
GET /stats看总条数,判断要不要清理GET /messages?sessionId=xxx拉要删的那条messageIdDELETE /messages/:messageId单条删(注意:跨实例/跨用户会返 500,按 500 兜底)- 再
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 性能坑位是一组。