· 从零构建一个 AI 聊天应用

从零构建一个 AI 聊天应用

3469 字
17 分钟
从零构建一个 AI 聊天应用
StarryLin/Geek · 摘要

本文介绍基于腾讯agent-sdk、Express与WebSocket构建AI聊天应用的完整实现,涵盖前后端架构、会话存储、协议设计与重连机制,并指出单轮限制、并发风险及持久化扩展建议。

示例版本说明:本文对照 CodeBuddy 官方示例仓库 提交 25953e3 核对。下文代码为结构摘录,省略了部分导入、类型和实现;博客仓库不包含这些示例目录。unstable_v2_* 属于不稳定接口,运行时请锁定依赖并对照当前文档。

从零构建一个 AI 聊天应用#

配套代码:CodeBuddy 官方示例仓库 的 chat-demo/ —— TypeScript + @tencent-ai/agent-sdk + Express + WebSocket + React 本文记录:SDK 流式输出如何接入服务端、WebSocket 如何把「公开文本、工具调用与回答」实时推给前端、多会话如何管理、断线如何恢复。 日期:2026-09-04


先看要点#

本教程带你搭一个**能选会话、能发消息、能实时看到智能体「正在调用什么工具」**的聊天 Web 应用。完整代码在上述官方示例仓库的 chat-demo/,跟着下面的步骤,你就是「抄一遍」也能学会架构。

核心链路只有一句:

React ──WS──► Express/WebSocket 服务 ──► ChatSession(一个会话一个智能体)
│
@tencent-ai/agent-sdk 的 query() 异步流
│
拆成 user/assistant/tool_use/result 事件广播回浏览器

〇、最终效果与目录结构#

chat-demo/
├── package.json
├── vite.config.ts # 开发代理:前端 /ws 与 /api → 4001
├── client/
│ ├── index.html
│ ├── App.tsx # 左 Sidebar + 右 MessagePanel
│ ├── components/ # Sidebar / MessagePanel / 气泡渲染
│ └── hooks/
│ ├── useChats.ts # REST:增删查会话
│ └── useMessages.ts # WebSocket:订阅 + 实时收消息
└── server/
├── config.ts # 所有可调参数集中地
├── types.ts # 入站/出站 WS 消息类型
├── store.ts # 内存会话与消息存储(LRU 清理)
├── agent.ts # 对 agent-sdk query() 的薄封装
├── chat-session.ts # 一个聊天 = 一个 Agent + N 个订阅者
└── server.ts # Express + WebSocketServer 入口

依赖清单(package.json 关键项):

{
"dependencies": {
"@tencent-ai/agent-sdk": "^0.3.34",
"express": "^4",
"cors": "^2",
"ws": "^8",
"react": "^18",
"react-use-websocket": "^4"
},
"devDependencies": {
"typescript": "^5.5", "vite": "^5", "tsx": "^4"
}
}

第一步:集中配置 server/config.ts#

所有「以后可能要调」的东西集中一处,后面代码里不要再出现魔法数字:

// chat-demo/server/config.ts(摘录)
export const config = {
server: {
host: process.env.HOST || '0.0.0.0',
port: parseInt(process.env.PORT || '4001', 10),
wsPath: '/ws',
},
chat: {
maxMessagesPerChat: 100, // 每个会话最多保留多少条消息
maxChats: 50, // 内存最多保留多少会话(超出淘汰最旧的)
defaultTitle: '新对话',
titleMaxLength: 50,
},
agent: {
// model: 'claude-opus-4.5' as const, // 不指定模型,使用默认配置
maxTurns: 1, // 想真正跑工具循环,建议调大到 20~50
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch'],
},
websocket: {
heartbeatInterval: 30000, // 心跳间隔,用于探测死连接
reconnectAttempts: 10,
reconnectInterval: 3000,
},
} as const;

💡 仓库里 maxTurns: 1 适合演示“单轮回答”;当你想让助手真正「动手」(搜索、读写文件),把它调到 20 以上,可用轮数才更充足;是否能完成还取决于工具、权限、模型和任务,不能由这个数保证。


第二步:把 SDK 包成 Agent 类#

我们不希望业务代码到处 query(),于是做一个薄封装:发一条消息 = 启动一个新的异步流,外部通过 getOutputStream() 消费。

chat-demo/server/agent.ts
import { query } from "@tencent-ai/agent-sdk";
import { config } from "./config.js";
const SYSTEM_PROMPT = `你是一个乐于助人的 AI 助手。你可以帮助用户完成各种任务,包括:
- 回答问题
- 撰写和编辑文本
- 编程和调试代码
- 分析和研究
- 创意任务
请保持回复简洁但全面。`;
export class Agent {
private currentStream: AsyncIterator<any> | null = null;
async sendMessage(content: string) {
// assistant.content 含 text / tool_use 块;result 标记本轮结束
this.currentStream = query({
prompt: content,
options: {
maxTurns: config.agent.maxTurns,
cwd: process.cwd(),
executable: "node",
allowedTools: [...config.agent.allowedTools],
systemPrompt: SYSTEM_PROMPT,
},
})[Symbol.asyncIterator]();
}
async *getOutputStream() {
if (!this.currentStream) throw new Error("No active stream. Call sendMessage() first.");
while (true) {
const { value, done } = await this.currentStream.next();
if (done) { this.currentStream = null; break; }
yield value;
}
}
close() { this.currentStream = null; }
}

要点:query() 的 options 里我们不传模型(注释:不指定模型,使用默认配置),本文省略模型配置,上游使用 config.agent.model;cwd 指向进程目录,让工具有明确的工作起点,但它不是文件访问沙箱。close() 仅清空引用,也不等于已经中断底层查询;需要使用当前 SDK 支持的取消/清理机制。


第三步:内存存储 store.ts(含自动清理)#

原型阶段没必要上数据库。这个 store 有两个「保鲜机制」:每个会话最多 100 条消息(超出丢掉最旧的),最多 50 个会话(超出按更新时间淘汰最旧,即简易 LRU)。另外,第一条用户消息会自动变成会话标题。

// chat-demo/server/store.ts(结构)
class ChatStore {
private chats = new Map<string, Chat>(); // 会话元数据
private messages = new Map<string, ChatMessage[]>(); // 消息
createChat(title?: string): Chat { /* 超容量则 evictOldestChat();uuid + 时间戳 */ }
getAllChats(): Chat[] { /* 按 updatedAt 倒序,最新在前 */ }
updateChatTitle(id, title) { /* … */ }
deleteChat(id): boolean { /* 同时删 messages */ }
addMessage(chatId, message) {
// push 后:超过 maxMessagesPerChat → splice(0, 超出部分)
// 标题仍是"新对话"且是用户消息 → 用首条消息生成标题(超长截断 + "...")
}
getMessages(chatId) { return this.messages.get(chatId) ?? []; }
private evictOldestChat() {
const oldest = this.getAllChats().at(-1);
if (oldest) this.deleteChat(oldest.id);
}
}
export const chatStore = new ChatStore(); // 单例

真要做产品,把这层换成 SQLite/Postgres(mail-assistant 就换成了 SQLite + FTS5),其余代码基本不动——存储抽象隔离得好,迁移成本就低。


第四步:ChatSession——一个会话就是一个小型「消息中心」#

这是全项目最有价值的一段设计。每个会话持有一个 Agent 和一组 WebSocket 订阅者:

  • 用户发消息 → 存库 + 广播 user_message → 交给 Agent;
  • Agent 的异步流里每冒出一条消息,就转发给所有订阅者(多端同屏的关键);
  • 把 SDK 的消息翻译成前端友好的事件。
// chat-demo/server/chat-session.ts(核心逻辑)
export class ChatSession {
public readonly chatId: string;
private subscribers: Set<WSClient> = new Set();
private agent = new Agent();
private isListening = false;
async sendMessage(content: string) {
chatStore.addMessage(this.chatId, { role: "user", content });
this.broadcast({ type: "user_message", content, chatId: this.chatId });
await this.agent.sendMessage(content); // 启动新流
if (!this.isListening) this.startListening(); // 只避免重复启动监听器;并不阻止并发 sendMessage 覆盖流
}
private async startListening() {
if (this.isListening) return;
this.isListening = true;
try {
for await (const message of this.agent.getOutputStream()) {
this.handleSDKMessage(message); // ← 翻译 + 广播
}
} catch (error) {
this.broadcastError((error as Error).message);
} finally {
this.isListening = false; // 流结束复位,可接下一轮
}
}
private handleSDKMessage(message: any) {
if (message.type === "assistant") {
const content = message.message.content;
if (typeof content === "string") {
// 纯文本
chatStore.addMessage(this.chatId, { role: "assistant", content });
this.broadcast({ type: "assistant_message", content, chatId: this.chatId });
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text") { // 文本块
chatStore.addMessage(this.chatId, { role: "assistant", content: block.text });
this.broadcast({ type: "assistant_message", content: block.text, chatId: this.chatId });
} else if (block.type === "tool_use") { // 模型要调用工具 → 单独事件
this.broadcast({ type: "tool_use", toolName: block.name,
toolId: block.id, toolInput: block.input, chatId: this.chatId });
}
}
}
} else if (message.type === "result") {
// 本轮结束:带成功与否 + 费用 + 耗时
this.broadcast({ type: "result", success: message.subtype === "success",
chatId: this.chatId, cost: message.total_cost_usd,
duration: message.duration_ms });
}
}
subscribe(client: WSClient) { this.subscribers.add(client); client.sessionId = this.chatId; }
unsubscribe(client: WSClient) { this.subscribers.delete(client); }
hasSubscribers(): boolean { return this.subscribers.size > 0; }
private broadcast(message: any) {
const messageStr = JSON.stringify(message);
for (const client of this.subscribers) {
if (client.readyState === client.OPEN) client.send(messageStr);
}
}
}

并发边界:上游示例的 isListening 只控制消费者数量,没有串行化发送操作。同一聊天的上一轮未结束时再次发送,可能覆盖 currentStream。产品实现需要按会话排队,或在服务端拒绝忙碌期间的新请求,并在异常路径清理运行状态。

第五步:把会话挂到服务上 server.ts#

HTTP 管「会话 CRUD」,WebSocket 管「实时消息」。sessions Map 负责把 chatId 映射到 ChatSession(惰性创建)。

// chat-demo/server/server.ts(摘录)
const sessions: Map<string, ChatSession> = new Map();
function getOrCreateSession(chatId: string): ChatSession {
let session = sessions.get(chatId);
if (!session) { session = new ChatSession(chatId); sessions.set(chatId, session); }
return session;
}
// —— REST 层:会话列表 / 创建 / 详情 / 删除 / 历史消息 ——
app.get("/api/chats", (req, res) => res.json(chatStore.getAllChats()));
app.post("/api/chats", (req, res) => { /* createChat(req.body?.title) */ });
app.get("/api/chats/:id", (req, res) => { /* getChat */ });
app.delete("/api/chats/:id", (req, res) => {
/* 删除会话时同步 close() 并移出 sessions,防止幽灵会话 */
});
app.get("/api/chats/:id/messages", (req, res) => res.json(chatStore.getMessages(req.params.id)));
// —— WebSocket 层 ——
const wss = new WebSocketServer({ server, path: WS_PATH });
wss.on("connection", (ws: WSClient) => {
ws.send(JSON.stringify({ type: "connected", message: "Connected to chat server" }));
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; }); // 心跳响应
ws.on("message", async (data) => {
const message: IncomingWSMessage = JSON.parse(data.toString());
switch (message.type) {
case "subscribe": { // ① 订阅会话
const session = getOrCreateSession(message.chatId);
session.subscribe(ws);
// 立即回放历史消息,让新打开的页面无缝续聊
ws.send(JSON.stringify({ type: "history", messages: chatStore.getMessages(message.chatId),
chatId: message.chatId }));
break;
}
case "chat": { // ② 发消息
const session = getOrCreateSession(message.chatId);
session.subscribe(ws); // 自动订阅,防漏消息
await session.sendMessage(message.content);
break;
}
}
});
ws.on("close", () => {
for (const session of sessions.values()) session.unsubscribe(ws); // 全量退订
});
});
// —— 心跳:30s 一次 ping,无 pong 的客户端直接 terminate ——
const heartbeat = setInterval(() => {
wss.clients.forEach((ws: WSClient) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, config.websocket.heartbeatInterval);

服务边界:这段服务代码为教学摘录;生产服务还需消息格式校验、JSON 解析异常处理、身份认证及 chatId 归属校验。切换会话时也要退订旧会话或按 chatId 过滤事件,防止旧回复混入新界面。

第六步:WS 协议一览#

入站(浏览器 → 服务端)

typepayload含义
subscribe{ chatId }订阅某个会话(服务端回 history)
chat{ chatId, content }发送一条用户消息

出站(服务端 → 浏览器)

typepayload含义
connected{ message }连接建立
history{ messages }历史消息回放
user_message{ chatId, content }用户消息(已本地乐观渲染,前端可忽略)
assistant_message{ chatId, content }一段助手文本(文本块拆分推送)
tool_use{ chatId, toolName, toolId, toolInput }助手正在调用某工具(用于实时展示)
result{ chatId, success, cost, duration }本轮结束 + 计费耗时
error{ chatId, error }出错

设计妙处:tool_use 是独立事件。于是前端可以把「搜网页 / 读文件 / 执行命令」这些过程实时亮给用户看——这是 Agent 聊天和普通 LLM 聊天体验上最大的分水岭。


第七步:前端 Hook useMessages#

用 react-use-websocket 一行拿到连接与消息,核心逻辑就是一个协议分发器:

// chat-demo/client/hooks/useMessages.ts(摘录)
export function useMessages({ wsUrl, onRefreshChats }) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleWSMessage = useCallback((message: any) => {
switch (message.type) {
case "history":
setMessages(message.messages || []); // 回放历史
break;
case "assistant_message": // 追加助手回复
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: "assistant",
content: message.content, timestamp: new Date().toISOString() }]);
setIsLoading(false);
break;
case "tool_use": // 追加工具调用卡片
setMessages((prev) => [...prev, { id: message.toolId, role: "tool_use", content: "",
toolName: message.toolName, toolInput: message.toolInput,
timestamp: new Date().toISOString() }]);
break;
case "result":
setIsLoading(false); // 本轮结束
onRefreshChats?.(); // 刷新会话列表(标题/时间变了)
break;
case "error":
console.error("Server error:", message.error);
setIsLoading(false);
break;
}
}, [onRefreshChats]);
const { sendJsonMessage, readyState, lastJsonMessage } = useWebSocket(wsUrl, {
shouldReconnect: () => true, // 断了自动重连
reconnectAttempts: 10,
reconnectInterval: 3000,
});
const isConnected = readyState === ReadyState.OPEN;
const subscribeToChat = useCallback((chatId: string) => {
setMessages([]); // 切会话先清屏
sendJsonMessage({ type: "subscribe", chatId }); // 服务端会回 history
}, [sendJsonMessage]);
const sendMessage = useCallback((chatId: string, content: string) => {
if (!chatId || !isConnected) return false;
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: "user", content, // 乐观渲染
timestamp: new Date().toISOString() }]);
setIsLoading(true);
sendJsonMessage({ type: "chat", content, chatId });
return true;
}, [isConnected, sendJsonMessage]);
return { messages, isLoading, isConnected, subscribeToChat, sendMessage, clearMessages };
}

三个细节值得背下来:

  1. 乐观渲染:发消息瞬间先把气泡插进列表,不等服务器回包;
  2. 切会话清屏 + history 回放:subscribeToChat 先 setMessages([]),随后服务端回 history 自动填充,恢复界面历史;不代表模型恢复了对话上下文;
  3. 自动重连:断网后 shouldReconnect: () => true,库只负责重新建立连接;上游 Hook 没有在 onOpen 自动重新发送订阅,subscribedChatRef 也未用于重订阅。必须另外补上重订阅及缺失消息补偿,才能恢复业务状态。

第八步:组装 UI App.tsx#

页面结构极其直白——左会话列表 + 右聊天面板:

// chat-demo/client/App.tsx(结构)
export default function App() {
const { chats, selectedChatId, fetchChats, createChat, deleteChat, selectChat } = useChats();
// 结构摘录:删除和发送处理函数见上游 App.tsx 完整实现。
const { messages, isLoading, isConnected, subscribeToChat, sendMessage, clearMessages }
= useMessages({ wsUrl: WS_URL, onRefreshChats: fetchChats });
const handleSelectChat = (chatId) => { selectChat(chatId); subscribeToChat(chatId); };
const handleNewChat = async () => { const chat = await createChat(); if (chat) handleSelectChat(chat.id); };
return (
<div className="flex h-screen">
<div className="w-64 shrink-0"> {/* 左:会话列表 Sidebar */}
<Sidebar chats={chats} selectedChatId={selectedChatId}
onSelectChat={handleSelectChat} onNewChat={handleNewChat} onDeleteChat={handleDeleteChat} />
</div>
<MessagePanel chatId={selectedChatId} messages={messages}
isConnected={isConnected} isLoading={isLoading}
onSendMessage={handleSendMessage} /> {/* 右:聊天面板 */}
</div>
);
}

上游 App.tsx 实际硬编码了 ws://${window.location.hostname}:4001/ws,并未按环境自动切换;Vite 虽配置了 /ws 代理,直连 4001 的地址却不会走该代理。生产环境应明确配置 WS 地址;HTTPS 页面需要 wss://。若通过同源反向代理发布 /ws,可按页面协议构造地址,不能直接照搬开发端口。


第九步:跑起来#

Terminal window
cd chat-demo
npm install
# 确保本机已登录 CodeBuddy / 已配置 API 密钥(如 CODEBUDDY_API_KEY)
npm run dev # 启动 Vite 与 server(4001)
# 以 Vite 输出的 Local 地址为准;核对的上游 vite.config.ts 使用 5174

验证清单:

□ 点「新建会话」→ 左侧出现「新对话」
□ 发一条消息 → 右侧先出现自己的气泡
□ 观察服务端日志:[ChatSession xxx] Received message: assistant / result(tool_use 在 assistant.content 中)
□ 若给模型开放了 WebSearch,消息里能看到 tool_use 卡片 → 前端出现「搜索中…」样式
□ 刷新页面重新打开会话 → history 回放,历史消息还在
□ 只断开网络、服务端不重启 → 重连后重新 subscribe,回放仍在内存中的消息
□ 停掉 server 再重启 → 内存 Map 会清空,不能把此场景写成历史自动恢复

实现时需要留意的四件事#

  1. 先接好消息流:这个示例由 SDK 处理 agent 循环,应用消费 query() 的流,把事件转换后广播给客户端。
  2. 一个会话 = 一个 Agent + 一堆订阅者:工具调用来自 assistant 消息中的 tool_use 内容块,并没有「顶层 tool_use 必定先于 assistant」的保证,前端需要分别展示工具状态和回答文本。
  3. 存储隔离决定演进空间:内存 Map → SQLite → Redis/Postgres,应通过存储接口隔离差异,业务层仍需核对事务、并发和持久化行为。
  4. 协议先行:先定下 subscribe/chat/history/tool_use 这套小协议,前后端就能并行开发,谁也不用等谁。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从零构建一个 AI 聊天应用
https://mjzy.tech/posts/ai-engineering/02-从零构建-ai-聊天应用/
作者
JIA
发布于
2026-09-04
许可协议
CC BY-NC-SA 4.0

评论区

正在加载 GitHub 评论…

Tips · 随手记
仅自己可见 · 每篇文章单独保存

文章目录