AI Chat 最小项目(流式 + 取消)
目标:用 React + Node 在 2-3 天内跑通“流式输出 + 可中断 + Prompt 可控”的最小闭环。
目录
- 项目目标与范围
- 架构图(最小闭环)
- 项目结构(最小可跑)
- 后端实现(SSE 流式)
- 前端实现(React Hook)
- 安装与运行(最少步骤)
- 性能优化(前端侧)
- 完整可运行项目源码目录
- 验收标准
- 常见坑排查清单
- 详细实现解析(后端)
- 详细实现解析(前端)
- Prompt 版本管理(最小可行)
- 接口约定(前后端对齐)
- Mermaid:流式消息时序
- 测试与调试
- 扩展思路(做成作品集)
- 运行与配置详解
- 体验优化细节
- 安全与稳定性
- 测试用例(最小)
- 扩展清单(下一个版本)
项目目标与范围
- 必须有:SSE 流式、可中断、可替换 Prompt
- 暂不需要:登录体系、复杂 UI、数据库
补充说明:
- 可替换 Prompt 表示:你能在
prompt.js里切换/对比不同版本的提示词 - 可中断 表示:前端
AbortController+ 服务端监听断开
架构图(最小闭环)
项目结构(最小可跑)
ai-chat-demo/
├── server/
│ ├── index.js
│ ├── llm.js
│ └── prompt.js
└── web/
├── src/
│ ├── App.jsx
│ ├── Chat.jsx
│ ├── useChat.js
│ └── api.js
└── package.json
后端实现(SSE 流式)
server/index.js
import express from "express";
import cors from "cors";
import { streamChat } from "./llm.js";
import { buildPrompt } from "./prompt.js";
const app = express();
app.use(cors());
app.use(express.json());
app.post("/chat", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const { message } = req.body;
const prompt = buildPrompt(message);
try {
for await (const chunk of streamChat(prompt)) {
res.write(`data: ${chunk}\n\n`);
}
res.write("data: [DONE]\n\n");
} catch {
res.write("data: [ERROR]\n\n");
} finally {
res.end();
}
});
app.listen(3001, () => console.log("http://localhost:3001"));
server/prompt.js
export function buildPrompt(userInput) {
return `
你是一个专业、谨慎的 AI 助手。
要求:
- 使用中文
- 逻辑清晰
- 不要编造事实
- 不确定就说明
用户输入:
${userInput}
`;
}
server/llm.js(先用 mock)
export async function* streamChat(prompt) {
const words = prompt.split("");
for (const w of words) {
await new Promise((r) => setTimeout(r, 50));
yield w;
}
}
切换真实模型(示例思路)
先用 mock 跑通流程,再换模型 SDK。
export async function* streamChat(prompt) {
// pseudo: 这里替换为真实模型 SDK 的 stream
// for await (const chunk of realSDK.stream(prompt)) yield chunk;
const words = prompt.split("");
for (const w of words) {
await new Promise((r) => setTimeout(r, 50));
yield w;
}
}
真实模型 SDK 实现版本(OpenAI 示例)
对应可运行源码目录:
docs/demos/ai-chat-demo。
server/llm.js(OpenAI SDK)
import OpenAI from "openai";
const hasKey = !!process.env.OPENAI_API_KEY;
const model = process.env.OPENAI_MODEL || "gpt-4o-mini";
export async function* streamChat(messages) {
if (!hasKey) return;
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const stream = await client.chat.completions.create({
model,
messages,
stream: true,
});
for await (const part of stream) {
const delta = part.choices?.[0]?.delta?.content;
if (delta) yield delta;
}
}
server/prompt.js
export function buildMessages(userInput) {
return [
{ role: "system", content: "你是专业、谨慎的 AI 助手。" },
{ role: "user", content: userInput },
];
}
最小接口定义(便于前后端对齐)
错误码与事件约定(建议统一)
-
data: [DONE]:结束 -
data: [ERROR]:失败 -
data: {"type":"delta","text":"..."}:推荐 JSON 结构 -
POST /chat
- 请求体:
{ message: string } - 返回:SSE 流(
data: xxx) - 结束:
data: [DONE]
- 请求体:
前端实现(React Hook)
web/src/api.js
export function chatStream(message, onMessage) {
const controller = new AbortController();
fetch("http://localhost:3001/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
signal: controller.signal,
}).then(async (res) => {
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
onMessage(decoder.decode(value));
}
});
return () => controller.abort();
}
web/src/useChat.js
import { useState } from "react";
import { chatStream } from "./api";
export function useChat() {
const [text, setText] = useState("");
const [loading, setLoading] = useState(false);
const [abort, setAbort] = useState(null);
const send = (message) => {
setText("");
setLoading(true);
const stop = chatStream(message, (chunk) => {
if (chunk.includes("[DONE]")) {
setLoading(false);
} else {
setText((t) => t + chunk.replace(/^data:\s*/, ""));
}
});
setAbort(() => stop);
};
return { text, loading, send, stop: abort };
}
SSE 解析建议(最小版)
你会收到类似:
data: 你
data: 好
data: [DONE]
只拼接 data: 后面的内容即可。若后端输出 JSON,可在前端做 JSON.parse。
安装与运行(最少步骤)
性能优化(前端侧)
- 80ms 节流刷新,避免每 token 触发渲染
- 流式阶段先纯文本,结束后再渲染 Markdown
cd ai-chat-demo/server
npm i express cors
node index.js
cd ai-chat-demo/web
npm i
npm run dev
完整可运行项目源码目录
- 源码位置:
docs/demos/ai-chat-demo - 包含:
server/(SSE + OpenAI SDK)与web/(React + Vite)
验收标准
- 能看到逐字流式输出
- 点击“中断”立即停止
- Prompt 更换后输出明显变化
常见坑排查清单
- 流式不工作:检查
Content-Type: text/event-stream - 无法中断:未使用 AbortController
- 输出混乱:未做 chunk 清洗(去掉
data:) - 跨域报错:前端 dev server 加代理或后端启用 CORS
- 乱码:确认
TextDecoder("utf-8")与编码一致
详细实现解析(后端)
SSE 响应格式
- 每一段都用
data:开头 - 每段后面必须有空行(`
`)
- 结束用
[DONE]作为约定信号
示例:
data: 你
data: 好
data: [DONE]
错误处理建议
try {
for await (const chunk of streamChat(prompt)) {
res.write(`data: ${chunk}
`);
}
res.write("data: [DONE]
");
} catch (e) {
res.write(`data: [ERROR]${e?.message || ""}
`);
} finally {
res.end();
}
关键响应头说明
Content-Type: text/event-stream:告诉浏览器这是 SSECache-Control: no-cache:避免缓存导致不刷新Connection: keep-alive:长连接
详细实现解析(前端)
SSE 解包最小逻辑
function parseSSE(raw) {
return raw
.split("
")
.filter((line) => line.startsWith("data:"))
.map((line) => line.replace(/^data:\s*/, ""))
.filter(Boolean);
}
状态流转(建议)
idle→streaming→donestreaming→stopped(用户中断)streaming→failed(异常)
Prompt 版本管理(最小可行)
// prompt.js
export const prompts = {
v1: (input) => `你是助手。问题:${input}`,
v2: (input) => `你是严谨助手,必须引用。问题:${input}`,
};
export function buildPrompt(input, version = "v1") {
return prompts[version](input);
}
接口约定(前后端对齐)
请求:
{ "message": "你好" }
响应(SSE):
data: 你
data: 好
data: [DONE]
Mermaid:流式消息时序
测试与调试
curl 验证
curl -N -X POST http://localhost:3001/chat -H 'Content-Type: application/json' -d '{"message":"你好"}'
常见日志点
- 请求开始/结束时间
- 用户输入长度
- 响应 tokens 数量
扩展思路(做成作品集)
- 增加“输入限制/提示词选择器”
- 增加“多模型切换”
- 增加“错误码与 TraceId 显示”
运行与配置详解
环境变量(最小)
OPENAI_API_KEY=xxx
可选配置
PORT=3001MODEL=gpt-4.1-miniMAX_TOKENS=600
请求/响应示例
请求:
{ "message": "请用一句话总结React" }
响应(SSE):
data: React 是用于构建UI的库。
data: [DONE]
体验优化细节
首 token 时间(TTFT)
- 目标:< 1.5s
- 方法:减少 prompt 体积 + 减少历史对话
输出节流
let buf = "";
let last = 0;
const interval = 80;
function onChunk(chunk) {
buf += chunk;
const now = Date.now();
if (now - last > interval) {
appendToUI(buf);
buf = "";
last = now;
}
}
安全与稳定性
- Key 不落前端:所有请求走服务端
- 限流:单用户并发 1-2 个
- 错误码:统一格式(便于前端兜底)
测试用例(最小)
- 输入短句 → 正常流式
- 输入长段 → 输出不超时
- 中断 → 立即停止
扩展清单(下一个版本)
- 添加“重新生成”按钮
- 添加“引用来源”占位
- 添加“会话历史”