Agent 后端问题记录(Koa / MongoDB)
Agent 服务和普通 Web 服务的本质差异:长连接、流式响应、长耗时任务、会话状态膨胀。
问题一:Koa SSE 响应被 gzip 中间件缓冲,流式变批量
现象:Koa 服务本地 SSE 流式正常,加了 compress() 中间件后变成攒到最后一次性输出。
原因:gzip 压缩需要凑满缓冲区才输出,SSE 这种持续小数据量的响应会被一直攒着。
解决:compress 中间件里对 text/event-stream 跳过压缩:
const compress = require('koa-compress')
app.use(compress({
filter(content_type) {
return !/text\/event-stream/i.test(content_type) // SSE 不压缩
},
}))
配套:SSE 响应还要手动设置头并绕过 Koa 的 body 缓冲:
async function sseHandler(ctx) {
ctx.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // 告诉 Nginx 不要缓冲
})
ctx.res.write('data: start\n\n')
for await (const token of llmStream()) {
if (ctx.res.writableEnded) break // 客户端断开检测
ctx.res.write(`data: ${JSON.stringify({ token })}\n\n`)
}
ctx.res.end()
}
问题二:500 轮会话把 MongoDB 文档撑到 10MB
现象:长会话 Agent 越用越慢,查一次会话要几百毫秒,监控显示文档已经 10MB+。
原因:把所有消息塞进一个 session 文档。MongoDB 单文档 16MB 硬限制,而且每次追加消息都要 rewrite 整个文档——读也读得多,写也写得多。
解决:分层存储,sessions 存元数据,messages 每条一个文档:
// sessions:元信息 + 持久记忆 + 已折叠摘要(文档 <100KB)
{
session_id: "sess_abc",
persistent_memory: { user_profile: {...}, key_facts: [...] },
conversation_summaries: [ // 旧轮次折叠成摘要
{ summary: "...", source_turn_range: [1, 10] }
],
last_active_at: ISODate(), // TTL 用这个字段
}
// messages:单条消息独立文档
{
session_id: "sess_abc",
turn_index: 21,
role: "assistant",
content: "北京今天晴天 25°C",
status: "done", // streaming/done/stopped/error
}
// 读最新 20 条:只查 messages,走索引,毫秒级
db.messages.find({ session_id })
.sort({ turn_index: -1 })
.limit(20)
旧消息折叠成摘要后归档到对象存储,messages 集合只保留热数据(最近 50 轮)。
问题三:TTL 索引把用户正在用的会话删了
现象:给会话加了 7 天过期(基于 created_at),用户第 7 天打开会话,正在聊天时文档被删了。
原因:TTL 索引的过期时间 = 索引字段的值 + expireAfterSeconds,created_at 是创建时的固定值,文档更新不会延后过期。
解决:TTL 基于 last_active_at 字段,每次访问更新它:
db.sessions.createIndex(
{ last_active_at: 1 },
{ expireAfterSeconds: 7 * 24 * 3600 }
)
// 每次用户操作会话时
db.sessions.updateOne(
{ session_id },
{ $set: { last_active_at: new Date() } }
)
注意:TTL 删除由后台线程每 60s 扫一次,过期文档最多存活 60s+,做逻辑要容忍这个延迟。
问题四:多 Agent 并发写会话状态,互相覆盖
现象:3 个子 Agent 并行执行,各自往 session 写结论,后写的把先写的覆盖了。
原因:经典的 read-modify-write 竞态。Agent 场景不需要强事务(可以重试),但需要保证不丢更新。
解决:乐观锁 + 版本号,冲突就重试:
async function updateWithOptimisticLock(sessionId, updateFn, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
const session = await Session.findOne({ session_id: sessionId })
const result = updateFn(session) // 基于最新版本做修改
const updated = await Session.updateOne(
{ session_id: sessionId, version: session.version }, // 乐观锁条件
{ $set: { ...result, version: session.version + 1 } }
)
if (updated.modifiedCount === 1) return updated // 写成功
await sleep(2 ** i * 100) // 指数退避后重试
}
throw new Error('乐观锁重试耗尽,疑似写入饥饿')
}
为什么不用 MongoDB 多文档事务:Agent 一次执行 5-10s,事务持有锁的时间太长,会阻塞整个库。Agent 可重试的特性让乐观锁是更优解。
问题五:pm2 集群下 SSE 长连接找不到状态
现象:pm2 开 4 个 worker,用户的 SSE 连接在 worker 1,后续 HTTP 请求被负载均衡打到 worker 3,会话状态读不到。
原因:SSE 连接和内存态状态都绑定在单个 worker 进程里,普通负载均衡不保证会话亲和。
解决:
- Nginx 层做 upstream hash,同一会话固定打到同一 worker:
upstream agent_backend {
hash $http_x_session_id consistent; # 按会话 ID 哈希
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
server 127.0.0.1:3004;
}
- 状态外部化:会话状态放 Redis,跨 worker 可读;SSE 推送通过 Redis Pub/Sub 广播,哪个 worker 都能发。
小结
| 问题 | 一句话方案 |
|---|---|
| SSE 被 gzip 缓冲 | compress filter 跳过 event-stream |
| 会话文档膨胀 | sessions + messages 分层存储 |
| TTL 提前删数据 | TTL 基于 last_active_at 字段 |
| 并发写冲突 | 乐观锁 version + 指数退避重试 |
| 集群会话丢失 | Nginx hash 亲和 + 状态外部化到 Redis |
