从零构建一个 AI 聊天应用
本文介绍基于腾讯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() 消费。
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 协议一览
入站(浏览器 → 服务端)
| type | payload | 含义 |
|---|---|---|
subscribe | { chatId } | 订阅某个会话(服务端回 history) |
chat | { chatId, content } | 发送一条用户消息 |
出站(服务端 → 浏览器)
| type | payload | 含义 |
|---|---|---|
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 };}三个细节值得背下来:
- 乐观渲染:发消息瞬间先把气泡插进列表,不等服务器回包;
- 切会话清屏 + history 回放:
subscribeToChat先setMessages([]),随后服务端回history自动填充,恢复界面历史;不代表模型恢复了对话上下文; - 自动重连:断网后
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,可按页面协议构造地址,不能直接照搬开发端口。
第九步:跑起来
cd chat-demonpm 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 会清空,不能把此场景写成历史自动恢复实现时需要留意的四件事
- 先接好消息流:这个示例由 SDK 处理 agent 循环,应用消费
query()的流,把事件转换后广播给客户端。 - 一个会话 = 一个 Agent + 一堆订阅者:工具调用来自
assistant消息中的tool_use内容块,并没有「顶层 tool_use 必定先于 assistant」的保证,前端需要分别展示工具状态和回答文本。 - 存储隔离决定演进空间:内存 Map → SQLite → Redis/Postgres,应通过存储接口隔离差异,业务层仍需核对事务、并发和持久化行为。
- 协议先行:先定下
subscribe/chat/history/tool_use这套小协议,前后端就能并行开发,谁也不用等谁。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
正在加载 GitHub 评论…