主线:一条聊天消息的一生
30 秒导读: 你在 LibreChat 里对一个 agent 发一句话,浏览器打的是
POST /api/agents/chat。 这一章端到端追这条请求:先过一串中间件(脱敏、审核、鉴权、装配),再进控制器主函数。 最关键的一点:控制器不会把回答顺着这条 HTTP 连接吐回去——它先建一个后台"生成作业", 立刻回一个streamId就结束 POST;前端再用另一条GET长连接(SSE)去订阅那个作业的输出。 生成和 HTTP 连接解耦,所以刷新、断网、切标签页都能重新接上同一次回答。
本章只讲流程编排与生命周期:中间件做了哪些前置活、请求怎么被拆成两段、中断(abort)怎么管、
消息和文件怎么被拼起来、标题什么时候生成。至于 provider 差异见 02,
AgentClient 内部与流式细节见 03,工具/MCP 见 04。
1. 先看全景:一次对话其实是"两段式"
老式聊天后端是一段式:POST 进来 → 一边生成一边往这条连接里流 → 连接断了这次回答就没了。
LibreChat 现在走的是可续(resumable)模型,把一次对话拆成两条独立的 HTTP 请求:
前端 后端
│
│ ① POST /api/agents/chat (带 text / agent_id / conversationId)
├─────────────────────────────────► 过中间件 → 控制器
│ 建后台生成作业(job)
│ ◄───────────────────────────────── 立刻回 { streamId, conversationId, status:"started" }
│ (POST 到此结束,连接正常关闭——这不算 abort)
│
│ ┌─ 后台:initializeClient → sendMessage → 落库 ─┐
│ │ (脱离任 何 HTTP 连接,自己跑) │
│ ② GET /api/agents/chat/stream/:streamId (SSE 长连接) │
├─────────────────────────────────► 订阅这个 job │
│ ◄═══ event: message / final / title ══════ 作业把 chunk 推给所有订阅者 ◄──────────┘
│
│ (想停就再打) ③ POST /api/agents/chat/abort { streamId }
├─────────────────────────────────► GenerationJobManager.abortJob()
怎么读这张图: 左边是浏览器,右边是后端。① 和 ② 是两条不同的 HTTP 请求,③ 是可选的停止请求。
三者共享一个身份证:streamId,而它恒等于 conversationId(会话 ID)。
这个设计带来两个直接后果,后面每一步都是围着它转的:
- POST 秒回、不等生成。 控制器在生成开始前就
res.json(...)了(request.js:238),因为工具加载 (尤其 MCP OAuth)可能要几秒,得让前端赶紧连上 SSE 才不漏事件。 - "连接关闭"不再等于"用户想停"。 一段式后端靠
res.on('close')判断用户 中断;可续模型里 POST 本来就会正常关闭,所以中断改由专门的POST /chat/abort触发(index.js:223的abortJob)。
这一章主要讲第 ① 段(POST 控制器)的生命周期;② 的 SSE 回放/重连细节属于流式,归 03。
2. 第一段:POST 进来,先过中间件流水线
路由挂载在 api/server/routes/agents/index.js。真正处理聊天的 chat.js 被挂在 /chat 下,
而且排在 SSE 订阅、abort、状态查询这些 GET 路由后面——因为那些不需要跑 buildEndpointOption
这种重中间件,要先被截住(index.js:333-345)。
一条 POST /api/agents/chat 命中 chat.js 后,会按顺序穿过下面这串 router.use
(chat.js:28-33)。顺序是有讲究的:先便宜的过滤/审核,再花钱的鉴权和装配。
| 顺序 | 中间件 | 干什么 | 源码 |
|---|---|---|---|
| 1 | createMessageFilterPii | 按配置扫描用户输入里的 PII(个人身份信息),命中就按策略脱敏/拦截 | packages/api/src/middleware/messageFilterPii.ts:createMessageFilterPii |
| 2 | moderateText | 若开了 OPENAI_MODERATION,把文本发去审核接口,被 flag 就 denyRequest 拒掉 | moderateText.js:7 |
| 3 | checkAgentAccess | 角色级权限:这个用户有没有 AGENTS.USE 权限 | access.ts:generateCheckAccess(chat.js:18) |
| 4 | checkAgentResourceAccess | 资源级权限:这个用户能不能 VIEW 这个具体的 agent_id | canAccessAgentFromBody.js:149 |
| 5 | validateConvoAccess | 这个 conversationId 是不是属于该用户(防越权读别人会话) | validate/convoAccess.js:32 |
| 6 | buildEndpointOption | 解析请求体、套用 modelSpec 预设,产出 endpointOption(含 agent 的懒加载 Promise) | buildEndpointOption.js:28 |
几个不显然但重要的点,分开说:
脱敏和审核为什么要扫"合并后的字符串"。 用户可能把一句话拆进"引用块"和"正文",单看每段都干净,
拼起来才是敏感内容。所以两个中间件都不是只扫 req.body.text,而是先用 getReferencedQuotes 归一化引用,
再把 mergeQuotedText(text, quotes) 合并串也一起扫——和 AgentClient 最终喂给模型的那份逐字一致
(moderateText.js:26-31、messageFilterPii.ts 同注释)。
两级鉴权是分工的,不是重复。 checkAgentAccess 管"你这个人能不能用 agent 功能"(角色权限);
checkAgentResourceAccess 管"你能不能看这个特定 agent"(资源 ACL)。临时(ephemeral)agent 没有资源
ACL,canAccessAgentFromBody 直接放行到下一层(canAccessAgentFromBody.js:176-178)。
buildEndpointOption 是装配的关键一步,但它不加载 agent。 对 agents 端点,它调 agents.buildOptions
(buildEndpointOption.js:23、build.js:buildOptions)。注意 agent 字段塞进去的是一个 Promise
(build.js:11 的 loadAgent(...)),没有 await——真正把 agent 读出来是后面控制器里 initializeClient
的活(见 §4)。这样中间件层不为一次可能被并发闸门挡掉的请求白白读库。
// build.js —— agent 是懒加载 Promise,装配期不 await(示意,非源码)
const agentPromise = loadAgent({ req, spec, agent_id, endpoint, model_parameters })
.catch(() => undefined); // 读不到不抛,留给控制器判空
return removeNullishValues({ endpoint, agent_id, model_parameters, agent: agentPromise });
过完这 6 层,req.body.endpointOption 就绪,请求交给控制器(chat.js:35-37 的 controller)。
3. 第二段:控制器主函数 ResumableAgentController
AgentController 只是个转发壳,一切都路由到 ResumableAgentController
(request.js:753-755)。这个函数就是本章的心脏,把它按时间切成 7 个阶段来看:
阶段① 并发闸门 + 生成 conversationId/streamId
│ checkAndIncrementPendingRequest / crypto.randomUUID
▼
阶段② 建后台作业 job + 立刻 res.json({streamId}) ← POST 在这里"回话"
│ GenerationJobManager.createJob
▼
阶段③ 挂"全员离场"监听(断连保存半成品)
▼
阶段④ initializeClient —— 装 agent、加载工具、造 AgentClient
│ (可能耗时;abort 信号来自 job.abortController)
▼
阶段⑤ client.sendMessage —— 真正跑模型(内部细节见 03)
│ 并行:若够格,addTitle immediate 已同时起跑
▼
阶段⑥ 落库:先存 user 消息,再存 response 消息,再 emitDone
│ 顺序是硬要求——防止前端追问时 parentMessageId 还没落库
▼
阶段⑦ 完成/中断/替换 三种收尾 + disposeClient
下面逐阶段拆。