我把本地 AI Chat 项目重构了一遍:用 LangChain.js + Ollama + Streamdown 搭了一个最小可扩展架构

1 阅读15分钟

我把本地 AI Chat 项目重构了一遍:用 LangChain.js + Ollama + Streamdown 搭了一个最小可扩展架构

这篇文章记录一次“从可用原型走向可维护架构”的过程。

目标不是一上来堆满能力,而是在改动范围可控的前提下,把一个本地聊天项目的几个核心层重新梳理清楚:

  • 大模型集成层:LangChain.js + Ollama
  • 内容渲染标准:Markdown + typed parts + Streamdown
  • 前端交互层:自定义 useChatStream Hook,只做最小多轮上下文
  • 输入与协议校验:Zod

如果你也在做自己的 AI 应用原型,或者正准备把一个“能跑”的 Demo 往“能持续迭代”的方向收一收,这篇内容应该会比较有参考价值。

项目前端截图.png


一、为什么要重构,而不是继续往上加功能?

很多 AI 项目的第一版都会很像:

  • 前端一个输入框
  • 后端直接调大模型接口
  • 返回一段字符串
  • 页面上把字符串渲染出来

这个阶段追求的是“先跑起来”,完全没问题。

但项目只要继续做,就会很快遇到几个问题:

  1. 模型接入层过于直连 代码里直接写死 Ollama 请求,后面要加推理模式、工具调用、结构化输出,服务端会越来越重。

  2. 前后端协议太薄 如果接口只有一个 prompt -> answer,那后面要支持“推理内容”和“最终答案”分开展示、支持来源、支持卡片化数据,都会很别扭。

  3. 前端状态和流式处理容易失控 自己手写流式读取并不难,但如果没有清晰的消息模型,后面加取消、重试、多轮上下文、推理区块,很容易越写越乱。

  4. 渲染层缺少统一标准 如果模型输出今天是纯文本,明天是 Markdown,后天又要支持 reasoning/source/table,前端很容易到处写分支判断。

所以这次重构,我给自己的目标很明确:

不追求一次做满,而是先把“架构骨架”搭对。


二、这次方案怎么定?

最终落地的技术组合是下面这套:

前端页面
  └─ useChatStream
      └─ /api/chat
          └─ Zod 校验
              └─ LangChain.js
                  └─ ChatOllama
                      └─ Ollama

返回内容
  └─ NDJSON 流
      └─ typed parts
          ├─ reasoning
          └─ text

前端渲染
  └─ Streamdown 渲染 Markdown

这套方案有几个关键词:

  • LangChain.js:用来统一模型接入层
  • Ollama:本地模型运行时
  • typed parts:统一消息内容结构
  • Streamdown:专门面向流式 Markdown 的渲染器
  • useChatStream:我们自己维护的最小聊天 Hook
  • Zod:把请求和流式协议都校验起来

注意,这里我没有引入 AI SDK。

不是 AI SDK 不好,而是当前这个阶段我更希望:

  • 控制抽象层数
  • 看清楚流式协议到底怎么跑
  • 保留足够简单的代码结构,便于后续写博客和总结经验

三、架构改造后,核心边界怎么划分?

这次重构,我把项目拆成了四个层次。

1. 模型接入层:LangChain.js + Ollama

这一层只负责一件事:

把“业务消息”送给模型,并把模型流式输出转换成前端能消费的协议。

为什么不用前端直接打 Ollama?

  • 模型密钥和地址不应该暴露在浏览器
  • 推理流拆分、异常兜底、取消传递都更适合在服务端做
  • 后续如果从 Ollama 切到其它 provider,改动面更小

2. 消息模型层:typed parts

这一层是我认为这次改造里最重要的一层。

我没有继续把消息定义成一整段字符串,而是改成:

export interface TextPart {
  type: 'text'
  text: string
  format: 'markdown'
}

export interface ReasoningPart {
  type: 'reasoning'
  text: string
  format: 'markdown'
  visibility?: 'collapsed' | 'expanded' | 'hidden'
}

export type MindMessagePart = TextPart | ReasoningPart

这样做的意义很大:

  • textreasoning 在结构上天然分离
  • 前端渲染时不需要再从一大段字符串里“猜”哪部分是推理
  • 后面如果要加 sourcetooltablecard,只需要继续扩展 part 类型

这就是“最小可扩展”的核心思路。

3. 传输协议层:NDJSON 流

这次没有上 SSE 的复杂协议,也没有直接绑定某个框架的消息协议,而是用了一层轻量 NDJSON。

原因很简单:

  • 它足够轻
  • 浏览器和服务端都很好处理
  • 很适合自己掌控流式细节

为了支持推理内容和正文拆分,我把流式 chunk 扩成了这样:

export type ChatStreamChunk =
  | { type: 'start'; messageId: string }
  | { type: 'reasoning-start'; partId: string }
  | { type: 'reasoning-delta'; partId: string; delta: string }
  | { type: 'reasoning-end'; partId: string }
  | { type: 'text-start'; partId: string }
  | { type: 'text-delta'; partId: string; delta: string }
  | { type: 'text-end'; partId: string }
  | { type: 'finish' }
  | { type: 'error'; message: string }

你可以把它理解成:

  • 先告诉前端“我要开始一条 assistant 消息了”
  • 再分别告诉前端“推理内容开始了”“答案正文开始了”
  • 然后按 chunk 追加文本

这个协议不复杂,但非常清晰。

4. 前端渲染层:Streamdown + Tailwind CSS

这一层只负责把 part 渲染出来。

  • text part 用 Streamdown 渲染 Markdown
  • reasoning part 用折叠区展示
  • 页面样式统一交给 Tailwind CSS

这一层的重点不是“UI 有多花”,而是:

把消息结构和渲染职责严格分开。


四、模型接入层为什么选 LangChain.js + Ollama?

1. LangChain.js 的价值,不是“更炫”,而是“更稳的抽象”

这次重构前,模型调用可以直接 fetch Ollama。

但我还是把服务端接入层换成了 LangChain.js + ChatOllama,原因主要有三个:

第一,模型消息结构更统一

前端传来的消息数组,可以在服务端先转成 LangChain message:

export function toLangChainMessages(messages: MindMessageInput[]): BaseMessage[] {
  const result: BaseMessage[] = []

  for (const message of messages) {
    const content = message.parts
      .filter(part => part.type === 'text')
      .map(part => part.text)
      .join('\n\n')
      .trim()

    if (!content) continue

    switch (message.role) {
      case 'system':
        result.push(new SystemMessage(content))
        break
      case 'assistant':
        result.push(new AIMessage(content))
        break
      default:
        result.push(new HumanMessage(content))
    }
  }

  return result
}

这个适配器很小,但意义很大:

  • 项目内部用自己的 MindMessage
  • 模型层用 LangChain 的标准消息
  • 两边职责分离,后面升级不会互相污染
第二,推理能力接入更自然

这次我需要支持“推理内容”和“最终答案”分开展示。

ChatOllama 在开启 think: true 后,会把 reasoning 放到 additional_kwargs.reasoning_content 里。

所以服务端就可以这样拆:

const model = new ChatOllama({
  model: request.options?.model ?? deps.defaultModel,
  baseUrl: deps.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://127.0.0.1:11434',
  temperature: request.options?.temperature ?? 0.3,
  numPredict: request.options?.maxTokens,
  think: request.options?.enableReasoning,
  streaming: true,
})

const modelStream = await model.stream(langChainMessages, {
  signal: context.signal,
})

后面只要从 chunk 里分别提 reasoning_content 和正文内容即可。

第三,后续扩展空间更好

当前我只做了最小聊天链路,但 LangChain 的好处是:

  • 后续要加 tool calling,可以继续往上接
  • 要加 structured output,也能顺着现在这层演进
  • 要从 Ollama 切到其他模型,也不至于重写整个服务层

从架构角度看,这就是典型的“先把边界站稳”。


五、为什么内容渲染标准一定要定成 Markdown + typed parts?

这是这次方案里我最想强调的一点。

很多 AI 项目初期都会图方便:

  • 模型直接返回一大段字符串
  • 前端直接渲染成 Markdown

这么做短期当然可以,但一旦出现这些需求,就会开始痛苦:

  • 我只想把推理过程折叠起来
  • 我只想让来源单独展示
  • 我希望表格和引用做特殊渲染
  • 我希望后面支持工具结果卡片

如果消息只是一个大字符串,那所有能力都只能靠“字符串解析”,会越来越脆。

所以我这次直接把消息模型定成:

export interface MindMessage {
  id: string
  role: 'system' | 'user' | 'assistant'
  parts: MindMessagePart[]
  createdAt: string
}

也就是说:

  • 消息是容器
  • 内容是 parts

当前只落了两种 part:

  • text
  • reasoning

但架构上已经为后续扩展留下位置了。

这类设计在 AI 应用里非常值得早做,因为它会直接影响后面所有能力的演进方式。


六、服务端怎么把“推理”和“答案”拆成两条流?

这是这次改造最关键的实现点之一。

在服务端,我把 LangChain 的模型流再包了一层,转成自己的 NDJSON 协议。

核心思路是:

  1. 每次请求先生成一条 assistant message
  2. 推理和正文分别拥有自己的 partId
  3. 收到 reasoning 就发 reasoning-delta
  4. 收到正文就发 text-delta

关键代码如下:

for await (const chunk of modelStream) {
  if (context.signal?.aborted || closed) {
    return
  }

  const reasoning = getReasoningText(chunk)
  const text = getChunkText(chunk)

  if (reasoning) {
    ensureReasoningPartStarted()
    writeChunk({
      type: 'reasoning-delta',
      partId: reasoningPartId,
      delta: reasoning,
    })
  }

  if (text) {
    ensureTextPartStarted()
    writeChunk({
      type: 'text-delta',
      partId: textPartId,
      delta: text,
    })
  }
}

这里有几个实现要点。

要点 1:不要把 reasoning 和 text 混成一个 part

这是协议设计的核心。

如果这里偷懒,直接把所有 token 都拼到一段正文里,前端后面就很难再做“推理折叠”和“答案正文”分区。

要点 2:用 partId 确保前端合并正确

为什么还要多一个 partId

因为在流式场景下,前端不是一次拿到完整内容,而是一段一段增量拼接。

所以:

  • messageId 用于定位是哪条 assistant 消息
  • partId 用于定位当前增量属于消息里的哪一个 part

这其实是一个很经典的流式协议设计细节。

要点 3:取消请求必须一路向下传

服务端不是只把浏览器连接关掉,而是把 AbortSignal 传给模型调用:

const modelStream = await model.stream(langChainMessages, {
  signal: context.signal,
})

这样用户在前端点“停止”时,服务端和模型层都能一起停下来。

这对本地模型尤其重要,不然很容易出现:

  • 前端停了
  • Ollama 还在后台继续跑

七、前端为什么保留自定义 useChatStream,而不是再上一个更重的抽象?

这是这次方案一个很有意识的取舍。

我没有上更完整的聊天 SDK,而是保留了一个自己维护的 useChatStream

原因是:

  • 当前功能面不大
  • 我需要真正掌握协议细节
  • 想把代码控制在“够用 + 清晰”的范围里

1. 多轮上下文怎么做?

方案非常简单:

  • 前端维护 messages[]
  • 每次发消息时,把当前历史消息一起发给 /api/chat
  • 服务端转成 LangChain 消息后送给模型

这就是最小多轮上下文。

代码也很直接:

const payload: ChatRequest = {
  conversationId: conversationIdRef.current,
  messages: nextMessages.map(toMessageInput),
  options: {
    model: DEFAULT_MODEL,
    enableReasoning: true,
  },
}

2. 为什么只回传 text,不回传 reasoning

这是这次方案里一个很关键的取舍。

在前端把消息转成请求体时,我只保留 text

function toMessageInput(message: MindMessage): MindMessageInput {
  return {
    role: message.role,
    parts: message.parts.filter(
      (part): part is MindMessageInput['parts'][number] => part.type === 'text'
    ),
  }
}

这么做的原因是:

  • reasoning 更像中间推理过程,不一定适合反复喂回模型
  • 最小实现阶段,保留“用户问题 + 助手答案正文”的上下文就够了
  • 这样上下文更干净,也更稳定

这是一种典型的“先保守设计,再逐步开放能力”的思路。

3. Hook 怎么处理流式增量?

前端在读取 NDJSON 后,会根据 chunk 类型把内容追加到不同 part 中:

case 'reasoning-delta':
  updateMessages(current =>
    appendPartDelta(current, activeStreamRef.current.messageId ?? '', 'reasoning', chunk.delta)
  )
  return

case 'text-delta':
  updateMessages(current =>
    appendPartDelta(current, activeStreamRef.current.messageId ?? '', 'text', chunk.delta)
  )
  return

这里的设计好处是:

  • Hook 只负责“消息状态机”
  • 组件只负责“如何展示”
  • 逻辑层和视图层没有拧在一起

八、为什么要用 Zod 做输入和协议校验?

AI 项目里有一个很常见的问题:

大家都在关注模型输出,却经常忽略“接口边界”。

但实际上,一旦是流式场景,协议只要有一个 chunk 不符合预期,前端状态就很容易乱掉。

所以这次我把 Zod 用在了两个地方。

1. 请求入口校验

后端 /api/chat 收到请求后,不是直接拿来用,而是先过 schema:

const json = await request.json()
const payload = chatRequestSchema.parse(json)

对应 schema:

export const chatRequestSchema = z.object({
  conversationId: z.string().min(1),
  messages: z.array(messageInputSchema).min(1),
  options: z
    .object({
      model: z.string().optional(),
      temperature: z.number().optional(),
      maxTokens: z.number().int().positive().optional(),
      enableReasoning: z.boolean().optional(),
    })
    .optional(),
})

这一步的价值在于:

  • 请求结构一眼就清楚
  • 非法请求可以明确返回 400
  • 后续演进字段时心里更有底

2. 流式 chunk 校验

前端读取 NDJSON 后,也不是直接用,而是逐行做 schema 校验:

const parsedChunk = chatStreamChunkSchema.safeParse(JSON.parse(trimmedLine))

if (!parsedChunk.success) {
  throw new Error('Invalid chat stream chunk.')
}

这一步非常关键。

因为它能防止:

  • 后端协议升级但前端没同步
  • 某个 chunk 缺字段
  • 某个字段类型写错

从工程角度看,Zod 在这里充当的是“运行时契约”的角色。

这对于 AI 项目尤其重要,因为流式协议一旦不稳定,问题往往很难排查。


九、Markdown 渲染为什么选 Streamdown,而不是普通 Markdown 渲染器?

如果只是静态 Markdown,普通渲染器也能用。

但 AI 聊天有个典型特征:

内容是流式长出来的,而不是一次性到齐的。

这就意味着渲染器必须能接受:

  • 还没闭合的代码块
  • 还没结束的列表
  • 还没完整收尾的表格

这也是为什么我选了 Streamdown

基础用法其实很简单

export function TextPartView({ part }: { part: TextPart }) {
  return (
    <div className="markdown-body text-[15px] leading-7 text-inherit">
      <Streamdown>{part.text}</Streamdown>
    </div>
  )
}

但真正要注意的是 Tailwind v4 的接入细节

这里踩了一个很典型的坑。

如果项目接入了 Tailwind CSS v4,而你直接用了 Streamdown,却没有补这两样东西:

  1. @source "../node_modules/streamdown/dist/*.js"
  2. streamdown 需要的设计变量

那么很容易出现:

  • 代码块样式异常
  • 表格边框不对
  • 工具条结构错位

所以最终我的全局样式是这样处理的:

@import "tailwindcss";
@import "streamdown/styles.css";

@source "../node_modules/streamdown/dist/*.js";

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --radius: 0.875rem;
}

这段配置非常值得单独记一下,因为它不是“页面美化”,而是 Streamdown 正常工作所需的运行条件


十、UI 层这次为什么顺手接了 Tailwind CSS?

虽然这次重点不是样式,但我还是把页面样式从内联 style 收到了 Tailwind CSS。

原因主要是:

  • 组件结构更清晰
  • 样式和组件更贴近
  • 后面写博客、调 UI、扩展页面时更轻

比如页面布局现在就是比较典型的聊天结构:

<main className="min-h-screen ...">
  <div className="mx-auto flex min-h-screen max-w-5xl flex-col gap-6 px-6 pb-7 pt-10">
    <header>...</header>
    <ChatMessageList messages={messages} status={status} />
    <div className="sticky bottom-0 ...">
      <ChatInputForm ... />
    </div>
  </div>
</main>

这个阶段我没有追求复杂交互,而是只把几个体验做稳:

  • 用户消息气泡和助手正文分离
  • 输入框固定在底部
  • 流式生成时保留轻量 loading
  • 推理内容默认折叠

对于原型阶段来说,这已经足够了。


十一、这套方案最适合什么阶段?

如果你现在的项目还处在下面这个阶段:

  • 想把本地 AI 聊天先跑稳
  • 想理解流式协议和前后端边界
  • 不想一开始就引入过多框架抽象
  • 但又希望未来能继续扩展

那这套方案其实很适合。

它的特点是:

优点

  • 技术边界清晰
  • 抽象层数适中
  • 代码量可控
  • 非常适合个人实践和写总结
  • 对后续扩展友好

目前刻意保留的简化点

  • 只做本地多轮上下文,不做持久化
  • 只支持 textreasoning 两种 part
  • 不做 RAG、不做工具调用、不做结构化卡片
  • 前端仍然是自定义 Hook,不追求全家桶式能力

换句话说,这是一套:

“现在够用,未来能长”的最小架构。


十二、最后总结:这次重构真正解决了什么?

如果只从功能角度看,这次看起来像是在做:

  • 接 LangChain
  • 支持推理流
  • 加 Tailwind
  • 换一个 Markdown 渲染器

但如果从架构角度看,它真正解决的是四件事:

1. 把模型层和业务消息层解耦了

项目内部用 MindMessage,模型层用 LangChain message,边界清晰。

2. 把“回答内容”从“单字符串”升级成了“结构化 parts”

后面继续加来源、工具结果、卡片渲染时,不需要推翻现有模型。

3. 把流式输出变成了一套可维护协议

前后端都知道:

  • 哪个 chunk 是推理
  • 哪个 chunk 是答案
  • 该怎么合并

4. 把前端状态控制在了最小闭环

useChatStream 没有追求大而全,但已经把这几个关键能力兜住了:

  • 多轮上下文
  • 流式增量
  • 取消请求
  • 错误处理
  • part 合并

对我来说,这就是这次重构最有价值的地方。

不是功能一下子变多了,而是以后再继续做的时候,不用每一步都重新拆地基。


结语

AI 应用开发很容易掉进一个误区:

只盯着模型能力,却忽略工程结构。

但真正能让项目走得更远的,往往不是“今天又接了哪个模型”,而是:

  • 你的消息协议是不是清晰
  • 你的模型接入层是不是可替换
  • 你的前端状态是不是可维护
  • 你的渲染标准是不是可扩展

这次我给这个本地聊天项目做的,其实就是这样一次“从 Demo 到架构雏形”的整理。

如果后面继续往下做,我比较看好的下一步会是:

  1. 增加 source part
  2. 做会话持久化
  3. 增加 tool calling
  4. 再考虑是否引入更完整的消息层 SDK

如果你也在做类似的项目,希望这篇文章能帮你少走一点弯路。

📦 完整代码

本博客对应的代码已发布 v0.0.4 版本:

👉 GitHub Release - v0.0.4

如果对你有帮助的话,可以点个Star!