跳到主要内容

AI Chat 最小项目(流式 + 取消)

目标:用 React + Node 在 2-3 天内跑通“流式输出 + 可中断 + Prompt 可控”的最小闭环。

目录

项目目标与范围

  • 必须有: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:告诉浏览器这是 SSE
  • Cache-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);
}

状态流转(建议)

  • idlestreamingdone
  • streamingstopped(用户中断)
  • streamingfailed(异常)

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=3001
  • MODEL=gpt-4.1-mini
  • MAX_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 个
  • 错误码:统一格式(便于前端兜底)

测试用例(最小)

  • 输入短句 → 正常流式
  • 输入长段 → 输出不超时
  • 中断 → 立即停止

扩展清单(下一个版本)

  • 添加“重新生成”按钮
  • 添加“引用来源”占位
  • 添加“会话历史”