我把本地 AI Chat 项目重构了一遍:用 LangChain.js + Ollama + Streamdown 搭了一个最小可扩展架构
这篇文章记录一次“从可用原型走向可维护架构”的过程。
目标不是一上来堆满能力,而是在改动范围可控的前提下,把一个本地聊天项目的几个核心层重新梳理清楚:
- 大模型集成层:
LangChain.js + Ollama- 内容渲染标准:
Markdown + typed parts + Streamdown- 前端交互层:自定义
useChatStreamHook,只做最小多轮上下文- 输入与协议校验:
Zod
如果你也在做自己的 AI 应用原型,或者正准备把一个“能跑”的 Demo 往“能持续迭代”的方向收一收,这篇内容应该会比较有参考价值。
一、为什么要重构,而不是继续往上加功能?
很多 AI 项目的第一版都会很像:
- 前端一个输入框
- 后端直接调大模型接口
- 返回一段字符串
- 页面上把字符串渲染出来
这个阶段追求的是“先跑起来”,完全没问题。
但项目只要继续做,就会很快遇到几个问题:
-
模型接入层过于直连 代码里直接写死 Ollama 请求,后面要加推理模式、工具调用、结构化输出,服务端会越来越重。
-
前后端协议太薄 如果接口只有一个
prompt -> answer,那后面要支持“推理内容”和“最终答案”分开展示、支持来源、支持卡片化数据,都会很别扭。 -
前端状态和流式处理容易失控 自己手写流式读取并不难,但如果没有清晰的消息模型,后面加取消、重试、多轮上下文、推理区块,很容易越写越乱。
-
渲染层缺少统一标准 如果模型输出今天是纯文本,明天是 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
这样做的意义很大:
text和reasoning在结构上天然分离- 前端渲染时不需要再从一大段字符串里“猜”哪部分是推理
- 后面如果要加
source、tool、table、card,只需要继续扩展 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 渲染出来。
textpart 用Streamdown渲染 Markdownreasoningpart 用折叠区展示- 页面样式统一交给 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:
textreasoning
但架构上已经为后续扩展留下位置了。
这类设计在 AI 应用里非常值得早做,因为它会直接影响后面所有能力的演进方式。
六、服务端怎么把“推理”和“答案”拆成两条流?
这是这次改造最关键的实现点之一。
在服务端,我把 LangChain 的模型流再包了一层,转成自己的 NDJSON 协议。
核心思路是:
- 每次请求先生成一条 assistant message
- 推理和正文分别拥有自己的
partId - 收到 reasoning 就发
reasoning-delta - 收到正文就发
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,却没有补这两样东西:
@source "../node_modules/streamdown/dist/*.js"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 聊天先跑稳
- 想理解流式协议和前后端边界
- 不想一开始就引入过多框架抽象
- 但又希望未来能继续扩展
那这套方案其实很适合。
它的特点是:
优点
- 技术边界清晰
- 抽象层数适中
- 代码量可控
- 非常适合个人实践和写总结
- 对后续扩展友好
目前刻意保留的简化点
- 只做本地多轮上下文,不做持久化
- 只支持
text和reasoning两种 part - 不做 RAG、不做工具调用、不做结构化卡片
- 前端仍然是自定义 Hook,不追求全家桶式能力
换句话说,这是一套:
“现在够用,未来能长”的最小架构。
十二、最后总结:这次重构真正解决了什么?
如果只从功能角度看,这次看起来像是在做:
- 接 LangChain
- 支持推理流
- 加 Tailwind
- 换一个 Markdown 渲染器
但如果从架构角度看,它真正解决的是四件事:
1. 把模型层和业务消息层解耦了
项目内部用 MindMessage,模型层用 LangChain message,边界清晰。
2. 把“回答内容”从“单字符串”升级成了“结构化 parts”
后面继续加来源、工具结果、卡片渲染时,不需要推翻现有模型。
3. 把流式输出变成了一套可维护协议
前后端都知道:
- 哪个 chunk 是推理
- 哪个 chunk 是答案
- 该怎么合并
4. 把前端状态控制在了最小闭环
useChatStream 没有追求大而全,但已经把这几个关键能力兜住了:
- 多轮上下文
- 流式增量
- 取消请求
- 错误处理
- part 合并
对我来说,这就是这次重构最有价值的地方。
不是功能一下子变多了,而是以后再继续做的时候,不用每一步都重新拆地基。
结语
AI 应用开发很容易掉进一个误区:
只盯着模型能力,却忽略工程结构。
但真正能让项目走得更远的,往往不是“今天又接了哪个模型”,而是:
- 你的消息协议是不是清晰
- 你的模型接入层是不是可替换
- 你的前端状态是不是可维护
- 你的渲染标准是不是可扩展
这次我给这个本地聊天项目做的,其实就是这样一次“从 Demo 到架构雏形”的整理。
如果后面继续往下做,我比较看好的下一步会是:
- 增加
sourcepart - 做会话持久化
- 增加 tool calling
- 再考虑是否引入更完整的消息层 SDK
如果你也在做类似的项目,希望这篇文章能帮你少走一点弯路。
📦 完整代码
本博客对应的代码已发布 v0.0.4 版本:
如果对你有帮助的话,可以点个Star!