深色模式
第五步 · Node.js 后端:SSE 流式服务
目标:把 mini-harness 组装成一个 HTTP 服务。对应文件:
final-project/apps/server/src/bootstrap.ts、index.ts。对照原理篇 08。
5.1 bootstrap():一份函数式的 profile
真实 dsh 用 profile/bundle/patch 分层组合出插件树(原理篇 08)。mini-harness 的对应物是 bootstrap()——同一个思想,形式从"配置文件"简化为"组装函数":
ts
export function bootstrap(): Bootstrap {
const ctx = new Context()
// 核心服务(服务即插件)
ctx.plugin({ name: 'llm', apply: (c) => new LlmRuntime(c) })
ctx.plugin({ name: 'tools', apply: (c) => new ToolRegistry(c) })
ctx.plugin({ name: 'system-prompt', apply: (c) => new SystemPrompt(c) })
ctx.plugin({ name: 'agents', apply: (c) => new AgentRegistry(c) })
// 模型提供方(两个适配器插件)
ctx.plugin(mockAdapterPlugin)
ctx.plugin(openaiCompatAdapterPlugin)
// persona 片段
ctx.plugin({ name: 'persona', inject: ['systemPrompt'], apply(c) {
c.get<SystemPrompt>('systemPrompt', true)!.section({
name: 'persona', order: 0,
text: '你是 mini-harness 教学 Agent。模型 {{model}},工作目录 {{cwd}}。…',
})
}})
// 工具集 + 策略钩子
ctx.plugin({ name: 'builtin-tools', inject: ['tools'], apply(c) { /* echo / calc */ } })
ctx.plugin({ name: 'server-policy', apply(c) { /* llm/stream 统计 + pre-execute 审批 */ } })
return { ctx, agents, providers }
}把这段代码与 dsh --profile headless --dump-config 的输出并排看:每一行插件、每一个 inject、每一个"提供方/工具/策略"的挂载位置,都是同一件事的两种写法。
5.2 REST 接口设计
| 接口 | 作用 |
|---|---|
GET /api/providers | 列出已注册提供方(来自 ctx.llm.listProviders()) |
POST /api/sessions | 创建 Agent(agents.create(id, { provider, model })) |
GET /api/sessions | 会话列表(含状态与事件数) |
POST /api/sessions/:id/messages | 发消息 → SSE 流 |
GET /api/sessions/:id/events | 会话日志全量 |
POST /api/sessions/:id/cancel | agent.cancel() |
5.3 SSE:把事件流接到浏览器
这是后端最值得读的一段(index.ts):
ts
const session = agent.session
const startSeq = session.seq // 本次请求开始时的日志长度
// 订阅该会话的事件流 → 转发给浏览器
const offEvent = ctx.on('session/event', (s, event) => {
if (s.id !== session.id) return
send({ type: 'session-event', seq: event.seq, event: event.type, data: event.data })
if (event.type === 'turn/end' && event.seq >= startSeq) {
offEvent() // 本次 turn 结束:收尾
send({ type: 'done', finalText: lastAssistantText(session) })
end()
}
})
void agent.followup({ role: 'user', content: [{ type: 'text', text }] })要点:
- 后端没有"为流式输出单独造数据"——它只是把会话日志的
session/event广播原样转发。文本增量、工具卡片、turn 边界,浏览器看到的一切都是日志; startSeq防止误判:一个会话可能并发多个请求,只有本次请求之后的turn/end才算本次结束;res.on('close')而不是req.on('close'):这是本教程真实踩过的坑——req的 close 在请求体被express.json()消费后就会触发,会立刻误杀 SSE 流。
5.4 常见错误
| 现象 | 原因 |
|---|---|
| SSE 只收到第一条事件就断开 | 用了 req.on('close')(见上) |
未知 provider | 拼写错误,或适配器插件没挂(看 GET /api/providers) |
| 真实模型 401 | OPENAI_API_KEY 没设置或 baseURL 拼错 |
| 前端 404 /api | Vite 代理未生效(确认 dev server 在 5174,后端在 4317) |
小练习:加一个 GET /api/sessions/:id/history
实现"投影历史"接口:返回 agent.session.deriveMessages()。然后比较它与 GET /api/sessions/:id/events 的差异——体会"日志"与"投影"的分层(原理篇 7.3)。