跳到主要内容

AI Chat 最小项目(流式 + 中断 + 可观测)

目标:3 天内做出一个“可真实演示”的聊天闭环,而不是只会调用 API 的 Demo。

目录

目标与边界

这部分你可以把它当作“项目验收合同”。
很多人做最小项目时容易把范围越做越大,最后既没跑通核心流程,也讲不清自己到底完成了什么。
先定义“必须有/可选有/先不做”,能让你始终围绕关键路径推进。

  • 必须有:流式输出、停止生成、统一错误码、traceId
  • 可选有:会话持久化、富文本渲染、多轮上下文压缩
  • 先不做:复杂权限体系、多人协作

最小架构

先看这张图的目的不是“画系统图”,而是明确三个职责边界:

  • 前端负责交互体验(输入、流式展示、停止按钮)
  • 服务端负责安全与稳定(鉴权、限流、转发、错误归一化)
  • 模型 API 只负责生成,不直接暴露给浏览器

这样拆分后,你后续要做的每个功能都能找到归属,不容易把逻辑写乱。

核心实现

服务端:SSE 转发 + 中断上游

这段代码要解决 4 件事:

  1. 把普通 HTTP 响应切成 SSE 流式响应,让前端能边收边显示
  2. 给每次请求打 traceId,便于排障和日志关联
  3. 在用户停止或断开时,及时中止上游模型请求,避免继续计费
  4. 统一输出 delta/done/error 三类事件,前端更容易写状态机

阅读时可以按“请求进入 -> 上游调用 -> 事件回传 -> 异常兜底 -> 释放连接”这个顺序看。

app.post("/api/chat/stream", async (req, res) => {
const traceId = crypto.randomUUID();
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Trace-Id", traceId);

const ac = new AbortController();
req.on("close", () => ac.abort("client_disconnected"));

try {
const upstream = await callModelStream(req.body, ac.signal);
for await (const delta of upstream) {
res.write(`data: ${JSON.stringify({ type: "delta", text: delta, traceId })}\n\n`);
}
res.write(`data: ${JSON.stringify({ type: "done", traceId })}\n\n`);
} catch (e) {
res.write(`data: ${JSON.stringify({ type: "error", code: "AI_UPSTREAM_ERROR", message: String(e), traceId })}\n\n`);
} finally {
res.end();
}
});

运行结果预期:

  • 正常时:前端持续收到 delta,最后收到 done
  • 异常时:前端收到 error,并能提示重试
  • 用户中断时:服务端也会停止上游,不再继续输出

前端:流式读取 + AbortController

这段前端代码要完成两件关键事:

  • 持续读取服务端的分片内容,并拼接成可展示文本
  • 把“停止生成”变成真实中断,而不是只改 UI 状态

实现思路是:fetch 发起请求后,通过 reader.read() 循环读取字节流,再把数据按 SSE 事件边界(\n\n)切开解析。

const controller = new AbortController();
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
signal: controller.signal,
});

const reader = res.body?.getReader();
const decoder = new TextDecoder();
let buf = "";
while (reader) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// 按 \n\n 分片并解析 data 行
}

最终你会得到一个可感知的流式体验:用户点击发送后几乎立即看到文字增长,点击停止后可以立刻停住,不会“停按钮了但后台还在跑”。

必须补齐的工程项

  • 安全:服务端持有 Key,前端不直连模型
  • 成本:限制 max_tokens 与输入长度
  • 可观测:每次请求输出 traceId、耗时、模型名
  • 兜底E_TIMEOUTE_RATE_LIMITE_UPSTREAM 三类错误码

验收标准

  • 能看到连续流式输出,不卡死
  • 点击“停止”后 300ms 内停止更新
  • 错误时展示可操作提示(重试/继续/缩短问题)
  • 控制台可拿到 traceId 做排障