Skip to content

第五步 · Node.js 后端:SSE 流式服务

目标:把 mini-harness 组装成一个 HTTP 服务。对应文件:final-project/apps/server/src/bootstrap.tsindex.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/cancelagent.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 }] })

要点:

  1. 后端没有"为流式输出单独造数据"——它只是把会话日志的 session/event 广播原样转发。文本增量、工具卡片、turn 边界,浏览器看到的一切都是日志;
  2. startSeq 防止误判:一个会话可能并发多个请求,只有本次请求之后的 turn/end 才算本次结束;
  3. res.on('close') 而不是 req.on('close'):这是本教程真实踩过的坑——req 的 close 在请求体被 express.json() 消费后就会触发,会立刻误杀 SSE 流。

5.4 常见错误

现象原因
SSE 只收到第一条事件就断开用了 req.on('close')(见上)
未知 provider拼写错误,或适配器插件没挂(看 GET /api/providers
真实模型 401OPENAI_API_KEY 没设置或 baseURL 拼错
前端 404 /apiVite 代理未生效(确认 dev server 在 5174,后端在 4317)

小练习:加一个 GET /api/sessions/:id/history

实现"投影历史"接口:返回 agent.session.deriveMessages()。然后比较它与 GET /api/sessions/:id/events 的差异——体会"日志"与"投影"的分层(原理篇 7.3)。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写