Day 7 结束时的 Python 版能跑,但有三个明显的门槛:
venv + pip install + ARK_API_KEY 一堆步骤。切 Next.js 一次性解决三件事:
┌────────────────────┐ ┌────────────────────────┐
│ app/page.tsx │ SSE │ app/api/chat/route.ts │
│ (Client React) │◀───────│ (Streaming Response) │
└────────────────────┘ └───────────┬────────────┘
│
▼
┌────────────────────────┐
│ lib/agent.ts │
│ KnowledgeAgent │
│ · runTurnStream() │
│ async generator │
└───┬──────────┬─────────┘
│ │
┌─────────────┘ └──────────┐
▼ ▼
┌─────────┐ ┌──────────┐
│lib/tools│ │lib/memory│
│ 7 tools│ │ 压缩逻辑│
└────┬────┘ └──────────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ lib/kb │ │Tavily │ │ lib/ │
│ SQLite │ │(联网) │ │embedding
│+cosine │ │ │ │(火山方舟)
└────────┘ └────────┘ └────────┘
关键设计:lib/agent.ts 只 yield 事件、不做任何 HTTP/UI;API route 把事件转成 SSE;React 消费 SSE 更新组件状态。引擎/表现层分离比 Day 6 更彻底,因为跨了进程边界。
| 层 | Python 版 | Web 版 | 差异说明 |
|---|---|---|---|
| LLM 客户端 | openai Python SDK | openai npm 包 | API 几乎一致 |
| Embedding | httpx + 火山方舟 multimodal API | fetch + 同一 API | 直接翻 |
| 向量库 | ChromaDB (PersistentClient) | SQLite + 手写 cosine | 见下一节 |
| Chunking | 段落切 + 滑窗 | 同上 | 参数对齐(500/50) |
| frontmatter | 手写正则 | 手写正则 | 直接翻 |
| Memory | ConversationMemory 类 | ConversationMemory 类 | 直接翻,压缩逻辑一致 |
| Agent loop | 普通 for 循环 + run_turn 返回 TurnResult | async generator + runTurnStream yield 事件 | Web 版原生流式 |
| Tool 调用 | OpenAI tool_calls | OpenAI tool_calls | schema 完全一样 |
| 联网工具 | httpx → Tavily | fetch → Tavily | 直接翻 |
| UI | Streamlit | React + Tailwind | 组件化 + 逐字流 |
Python 版用 ChromaDB —— 但它对 Web 部署不友好(要么起 Docker 服务,要么用 Chroma Cloud)。Web 版换成 SQLite + 手写 cosine 相似度。
CREATE TABLE chunks (
id TEXT PRIMARY KEY, -- "go-tips.md#0"
file TEXT,
chunk_idx INTEGER,
text TEXT,
embedding BLOB, -- Float32Array 的字节序列
tags TEXT
);
function cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
SQLite BLOB 只认字节。TypedArray 恰好有 .buffer 属性给你原始 ArrayBuffer:
// 存
function embeddingToBlob(v: number[]): Buffer {
const arr = new Float32Array(v);
return Buffer.from(arr.buffer);
}
// 取
function blobToEmbedding(buf: Buffer): Float32Array {
return new Float32Array(
buf.buffer,
buf.byteOffset,
buf.byteLength / Float32Array.BYTES_PER_ELEMENT,
);
}
每个 float 4 字节;1024 维向量 = 4 KB。55 个 chunks 全放内存 = 220 KB,比一个头像还小。
Web 版原生做了流式(Python 教程里只写了设计笔记,见 streaming-notes.md)。核心两个动作:
async *runTurnStream(userInput: string): AsyncGenerator<TurnEvent> {
this.memory.append({ role: "user", content: userInput });
for (let round = 1; round <= this.maxRounds; round++) {
const stream = await this.client.chat.completions.create({
model: this.model,
messages: this.memory.getMessages(),
tools: TOOLS_SCHEMA,
stream: true,
stream_options: { include_usage: true },
});
// 累加器:拼出完整 assistant 消息
let contentBuf = "", reasoningBuf = "";
const toolCallsBuf: Record<number, {id, name, arguments}> = {};
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta;
if (delta.content) {
contentBuf += delta.content;
yield { type: "answer_delta", text: delta.content }; // 一个字就吐一个字
}
if (delta.reasoning_content) { // Doubao 特有
reasoningBuf += delta.reasoning_content;
yield { type: "thought_delta", text: delta.reasoning_content };
}
// tool_calls 按 index 归类累加
for (const tc of delta.tool_calls ?? []) {
const slot = (toolCallsBuf[tc.index] ??= {id: null, name: "", arguments: ""});
if (tc.function?.arguments) slot.arguments += tc.function.arguments;
// ...
}
}
// 执行工具、yield tool_call_start / tool_call_result
}
yield { type: "turn_done", result };
}
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for await (const event of agent.runTurnStream(message)) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
},
});
const resp = await fetch("/api/chat", { method: "POST", body: ... });
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// 按 \n\n 切分事件块,解析 data: 行
}
tc.index 归类,不能按到达顺序拼。OpenAI 允许并行 tool_call —— index=0/1/2 三个 tool 的 name/arguments 是交错来的。按顺序 concat 会拼出坏 JSON。
stream_options: { include_usage: true } 别忘:默认流式响应不带 usage,token 统计会归零。
用户等答案久了想停止 —— Stop 按钮不是"只停止 UI 渲染",而是要把 abort 信号一路穿透到 OpenAI SDK 和 web_search fetch,同时回滚 memory 避免下一 turn 400。
点击 ⏹ → controller.abort()
│
▼
fetch("/api/chat", { signal }) 被 abort
│ (req.signal fire)
▼
API route 拿到 req.signal
│
▼
agent.runTurnStream(msg, req.signal)
│
┌────┴────┐
▼ ▼
OpenAI SDK web_search fetch
create(..., {signal}) fetch(..., {signal: AbortSignal.any([...])})
│ │
▼ ▼
抛 APIUserAbortError / AbortError
│
▼
agent catch → memory.rollbackTo(baseline) → 重新抛
│
▼
route catch → 判断是 abort → 发 {type:"aborted"} 而不是 error
const abortRef = useRef<AbortController | null>(null);
async function submit() {
const controller = new AbortController();
abortRef.current = controller;
let userAborted = false;
try {
const resp = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ message }),
signal: controller.signal, // 传给 fetch
});
// ...consume SSE...
// 事件里如果拿到 {type:"aborted"} → userAborted = true
} catch (e) {
if ((e as Error).name === "AbortError") userAborted = true;
} finally {
abortRef.current = null;
}
}
const stopGeneration = () => abortRef.current?.abort();
useRef 不用 useState —— setState 异步、StrictMode 下 effect 可能跑两次;ref 是同步、直接的容器,abort 时机不能错。
// lib/agent.ts
async *runTurnStream(userInput: string, signal?: AbortSignal) {
// 记录基线:abort 后回滚到这里
const memoryBaseline = this.memory.size();
this.memory.append({ role: "user", content: userInput });
try {
for (let round = 1; round <= this.maxRounds; round++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const stream = await this.client.chat.completions.create(
{ model, messages, tools, stream: true, ... },
{ signal }, // ← OpenAI SDK 会把 signal 透传到底层 fetch
);
// ... consume stream, execute tools with { ...ctx, signal } ...
}
} catch (e) {
const name = (e as Error).name;
if (name === "AbortError" || name === "APIUserAbortError") {
// 回滚:把这个 turn 加进去的所有消息撤掉。
// 否则下一 turn 会有 "assistant.tool_calls 但没跟 tool response" → 400
this.memory.rollbackTo(memoryBaseline);
}
throw e;
}
}
// lib/tools.ts
const timeoutSignal = AbortSignal.timeout(15000);
const signal = ctx.signal
? AbortSignal.any([ctx.signal, timeoutSignal]) // Node 20+
: timeoutSignal;
await fetch("https://api.tavily.com/search", {
method: "POST",
body: JSON.stringify({ query, ... }),
signal,
});
| 问题 | 解决 |
|---|---|
AbortError 名字不统一:浏览器 fetch 抛 AbortError,OpenAI SDK 抛 APIUserAbortError |
三个都判:name === "AbortError" || name === "APIUserAbortError" || /abort/i.test(msg) |
| 直接抛出去下一 turn 会 400 | catch 到 abort → memory.rollbackTo(memoryBaseline) → 再 rethrow |
| web_search 长跑请求 abort 不动 | signal 传到 fetch;同时用 AbortSignal.any([userAbort, timeout]) 复用同一路径 |
| 用户看不出"我到底停了没" | 后端发 {type:"aborted"} 事件;前端把已流部分 + _(已停止)_ 作为最终气泡内容 |
| 压缩失败杀 turn(早前的 bug) | maybeCompress() 独立 try/catch;答案已流出去就不能因为压缩失败被吞掉 |
cd agents-web
cp .env.example .env.local
# 编辑 .env.local 填入 ARK_API_KEY / ARK_ENDPOINT_ID / ARK_EMBEDDING_ENDPOINT_ID
npm install
npm run dev
# 打开 http://localhost:3000
# 侧栏点 "🔄 仅重建索引" 建库(约 10 秒)
# 然后就能问了
预期体验:
问 "go 怎么处理错误的" 之后的实际截图:

能看到的能力(对照截图从上到下):
AbortController → 后端 req.signal → OpenAI SDK 和 web_search fetch 一起断,memory 回滚到 turn 起点保证下一 turn 干净```go … ``` 三反引号包起来的代码块原样保留(当前用 whitespace-pre-wrap,未来可接 react-markdown 做真正的高亮)[来源: go-tips.md]list_notes(第 1 轮)再 search_notes(第 2 轮),两轮都是可折叠卡片reasoning_content —— 用户能看到"Agent 为什么调这个工具"3 轮 · 2 次工具调用 · 11094 tokens3481 / 3000 字符,进度条橙色告警下一 turn 会触发压缩| 坑 | 解决 |
|---|---|
ARK_EMBEDDING_ENDPOINT_ID Python 版是从 shell env 读的,切 JS 时忘了写进 .env.local | 把它跟 ARK_ENDPOINT_ID 一起显式写进 .env.local |
Next.js 16 默认没有 src/ 目录(--src-dir 才有) | create-next-app --src-dir |
Node better-sqlite3 是 native 模块,Vercel 部署会踩坑 | 本地开发用它;部署时切 Turso(libSQL)或 Neon Postgres |
SSE 事件里 data: 后面可能有多个 \n,客户端解析要按 \n\n 分块 | 标准 SSE 规范:块之间空一行分隔 |
Streamlit 版 st.session_state 保存 Agent 实例;Web 版没有 session 概念,Agent 挂 globalThis 上 | 单进程内不丢;serverless 冷启动会重建 —— 想跨请求保留 memory 就写 SQLite |
Tailwind v4 不用 tailwind.config.js 了,全部走 CSS @theme | 用 v4 默认配置就够了,本项目零自定义 |
create-next-app 默认给 globals.css 加了 @media (prefers-color-scheme: dark),跟硬编码亮色 UI 打架,页面变暗看不清 | 删掉 dark media query,body 加 color-scheme: light 锁定;bg 显式化到 bg-gray-50 / bg-white |
问题问到 3000+ 字符触发记忆压缩,压缩 LLM 调用抛异常 → generator 死了 → 前端拿不到 turn_done → 显示"(无回复)" | 关键修:(1) maybeCompress() 独立 try/catch,答案已流出就不能因为压缩失败被吞掉;(2) 前端把"累加的答案字符"作为兜底真相源,turn_done 只是锦上添花 |
前端 setActive(prev => ...) updater 里给外层闭包变量赋值 —— React StrictMode 下会跑两次,且 setState 是异步的,闭包变量的赋值不同步反映事件 | 把"累加/终态"数据放到 普通闭包变量,setState updater 只做渲染同步;组装 history 条目时优先用闭包里的答案 |
import Database from "@libsql/client",直接上 VercelAbortController 传给 fetch,前端"停止"按钮ARK_BASE_URL 换成 http://localhost:11434/v1(Ollama)