🌍 Web 全栈版 · Next.js + TypeScript

预计时间:3-4 小时 · 难度:⭐⭐⭐ · 目标:把 Day 1-7 手写的 Python 助手换成一份 Next.js 全栈实现,同一份能力(RAG + Tool Use + ReAct + 联网 + 流式),可上 Vercel
📖 本页路线

为什么切 Web 栈

Day 7 结束时的 Python 版能跑,但有三个明显的门槛:

  1. 用户要装 Python 环境venv + pip install + ARK_API_KEY 一堆步骤。
  2. Streamlit 部署重:想给别人用只能自己跑云服务器,或者用 Streamlit Cloud(有额度限制)。
  3. 没法逐字流式:Streamlit 的执行模型是"每次交互整个脚本从头跑",答案只能等 tool loop 全跑完再一次性 render。

切 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 版

Python 版Web 版差异说明
LLM 客户端openai Python SDKopenai npm 包API 几乎一致
Embeddinghttpx + 火山方舟 multimodal APIfetch + 同一 API直接翻
向量库ChromaDB (PersistentClient)SQLite + 手写 cosine见下一节
Chunking段落切 + 滑窗同上参数对齐(500/50)
frontmatter手写正则手写正则直接翻
MemoryConversationMemory 类ConversationMemory 类直接翻,压缩逻辑一致
Agent loop普通 for 循环 + run_turn 返回 TurnResultasync generator + runTurnStream yield 事件Web 版原生流式
Tool 调用OpenAI tool_callsOpenAI tool_callsschema 完全一样
联网工具httpx → Tavilyfetch → Tavily直接翻
UIStreamlitReact + Tailwind组件化 + 逐字流

向量库:ChromaDB → SQLite + 手写 cosine

Python 版用 ChromaDB —— 但它对 Web 部署不友好(要么起 Docker 服务,要么用 Chroma Cloud)。Web 版换成 SQLite + 手写 cosine 相似度

存储 schema

CREATE TABLE chunks (
  id TEXT PRIMARY KEY,        -- "go-tips.md#0"
  file TEXT,
  chunk_idx INTEGER,
  text TEXT,
  embedding BLOB,             -- Float32Array 的字节序列
  tags TEXT
);

Cosine 就是三行数学

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));
}
💡 教学价值最大的一段代码:手写一遍你就理解了"向量搜索"到底是什么 —— 把 embedding 想成高维空间里的箭头,cosine 相似度就是两根箭头夹角的余弦;越接近 1 越相似。
ChromaDB / Pinecone / pgvector 底层都在做同一件事,只是加了索引结构(IVF、HNSW)让"找 top-k 最相似"从 O(N) 降到 O(log N)。55 chunks 时,纯遍历 < 5ms 就出结果,不用索引。

Float32Array 的字节魔法

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,比一个头像还小。


流式对话:async generator + SSE

Web 版原生做了流式(Python 教程里只写了设计笔记,见 streaming-notes.md)。核心两个动作:

1) Agent 层:async generator yield 事件

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 };
}

2) API route:generator → SSE

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",
  },
});

3) 前端:fetch + ReadableStream 消费

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: 行
}
⚠️ tool_calls 分片必须按 tc.index 归类,不能按到达顺序拼。OpenAI 允许并行 tool_call —— index=0/1/2 三个 tool 的 name/arguments 是交错来的。按顺序 concat 会拼出坏 JSON。
⚠️ stream_options: { include_usage: true } 别忘:默认流式响应不带 usage,token 统计会归零。

可中断 streaming:signal 一路穿透

用户等答案久了想停止 —— Stop 按钮不是"只停止 UI 渲染",而是要把 abort 信号一路穿透到 OpenAI SDKweb_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

1) 前端:AbortController 存 useRef

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 时机不能错。

2) 后端:signal 穿透 + memory 回滚

// 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;
  }
}

3) web_search:AbortSignal.any 合并用户 abort + 15s 超时

// 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 怎么处理错误的" 之后的实际截图:

agents-web 运行截图:多轮 tool_call + Thought + 代码块 + 记忆进度条

能看到的能力(对照截图从上到下):


🕳️ 踩坑

解决
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 条目时优先用闭包里的答案

🚀 下一步方向

  1. 🟢 换 Turso(libSQL 兼容 SQLite):改一行 import Database from "@libsql/client",直接上 Vercel
  2. 🟢 可中断 streamingAbortController 传给 fetch,前端"停止"按钮
  3. 🟡 本地 LLM 后端ARK_BASE_URL 换成 http://localhost:11434/v1(Ollama)
  4. 🟡 多 Agent 编排:LangGraph.js,或者手写 handoff
  5. 🔴 发布:域名 + Vercel + Turso + 用户认证(NextAuth),让别人真能用