普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月27日首页

不只是接个计算器:我是怎么把 Tool Calling 做成可扩展骨架的

作者 倾颜
2026年3月27日 00:02

本文对应项目版本:v0.0.5

在前一个版本里,我已经把项目的基础聊天链路搭起来了:

  • 服务端使用 LangChain.js + Ollama
  • 前端使用自定义 useChatStream
  • 内容渲染采用 Markdown + typed parts + Streamdown
  • 输入和协议校验使用 Zod

到这一步,项目虽然已经具备了“本地大模型对话 + 流式输出 + 多轮上下文”的基础能力,但它本质上还是一个纯文本问答系统。

v0.0.5 我想解决的问题,不是“再接一个功能”,而是让系统第一次具备调用外部能力的能力。

为了控制复杂度,这一版我只给自己定了一个目标:

先接一个最小 Tool,跑通完整 Tool Calling 链路,同时保留后续自然扩展的空间。

当前版本只接入了一个工具:

  • calculator

但这一版真正要验证的,不是“计算器能不能算题”,而是下面这些工程问题:

  • 模型怎么决定要不要调用工具?
  • 工具参数怎么校验?
  • 工具结果怎么回填给模型?
  • 前端怎么把推理、工具调用、最终答案区分开来展示?
  • 如果一次流式渲染异常,会不会影响下一次对话?

这些问题,才是 Tool Calling 从 Demo 走向工程实现时真正需要面对的部分。

项目效果

聊天主界面截图

chat1.png

推理过程 + tool part工具调用成功 + 最终答案完整展示图

toolsucess.png

这版到底想做什么

先说清楚 v0.0.5 的边界。

这版不是:

  • 多工具 Agent
  • 工具编排平台
  • LangGraph 工作流
  • 长期记忆系统

这版只做一件事:

用一个最小 Tool Calling 实践,验证当前聊天系统能不能从“只会回答”升级成“会调用能力、会返回结构化结果、还能保持工程可扩展”。

所以这一版的设计原则非常明确:

  • 最小可实现
  • 可扩展
  • 不推翻前一版架构
  • 不引入新的重型框架

总体架构

这一版的整体链路如下:

用户输入
  -> /api/chat
    -> LangChain ChatOllama
      -> 判断是否返回 tool_calls
        -> calculator
          -> ToolMessage 回填
            -> 最终答案流式返回前端

如果把这条链路拆开来看,前后端分别承担的职责其实很清楚。

服务端负责

  • 统一模型接入
  • 挂载当前可用工具
  • 解析模型返回的 tool_calls
  • Zod 校验工具参数
  • 执行工具
  • 把工具结果回填给模型
  • 输出结构化 NDJSON 流

前端负责

  • 读取 NDJSON 流
  • reasoning / tool / text 三类 part 消费事件
  • 渲染推理过程、工具调用状态和最终答案
  • 在多轮对话里只保留必要上下文

为什么我没有继续把返回内容做成“一段字符串”

如果系统只有普通对话,把响应内容当作一段 Markdown 字符串其实是够用的。

但到了 Tool Calling 场景,这种做法就不够了。

因为一次回答里其实会同时包含三类信息:

  1. 模型推理内容
  2. 工具调用过程与工具结果
  3. 最终回答正文

如果仍然全部塞进一段字符串里,前端就会遇到几个问题:

  • 无法单独折叠推理过程
  • 无法结构化展示 tool 调用状态
  • 无法清晰地区分“工具结果”和“模型最后组织出来的答案”
  • 后续增加更多工具时,协议会越来越乱

所以这一版我把 assistant 消息拆成了三类 part:

  • reasoning part
  • tool part
  • text part

这样前端拿到的就不再是“文本流”,而是一种结构化事件流

关键代码:消息 part 结构

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

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

export interface ToolPart extends BasePart {
    type: 'tool'
    toolName: string
    status: 'called' | 'completed' | 'failed'
    input: string
    output?: string
    error?: string
}

关键代码:流式 chunk 协议

export interface ToolStartChunk {
    type: 'tool-start'
    partId: string
    toolName: string
    input: string
}

export interface ToolEndChunk {
    type: 'tool-end'
    partId: string
    toolName: string
    input: string
    output: string
}

export type ChatStreamChunk =
    | StartChunk
    | ReasoningStartChunk
    | ReasoningDeltaChunk
    | ReasoningEndChunk
    | ToolStartChunk
    | ToolEndChunk
    | ToolErrorChunk
    | TextStartChunk
    | TextDeltaChunk
    | TextEndChunk
    | FinishChunk
    | ErrorChunk

我后来越来越确信一点:

对 Tool Calling 场景来说,最值得先做对的不是 UI,而是消息协议。

因为协议一旦分层清楚,前端展示、错误定位、后续扩展都会轻松很多。

服务端设计:统一模型接入层 + 最小 Tool Calling 闭环

这一版我有一个很明确的取舍:

不把服务端讲成“普通模型一套、工具模型一套”。

更准确的说法应该是:

服务端维护一个统一的大模型接入层,在运行时根据当前可用工具集合决定是否给模型挂载工具能力。

也就是说,本质上仍然是一个基础模型配置,只是运行时会根据 activeTools 决定是否调用 bindTools()

这样的好处是:

  • 架构更干净
  • 更符合主流 Tool Calling 的实现方式
  • 后续新增工具时,不需要推翻主链路

执行流分成三种情况

1. 当前有可用工具,但当前问题不需要工具

user message
  -> baseModel.bindTools(activeTools)
    -> model response
      -> no tool_calls
        -> final answer

特点:

  • 只调用一次模型
  • 不会多走一轮
  • 即使模型已经绑定工具,也可以直接正常回答

2. 当前有可用工具,且当前问题需要工具

user message
  -> baseModel.bindTools(activeTools)
    -> model response
      -> tool_calls
        -> Zod 校验
        -> execute tool
        -> append ToolMessage
        -> second model response
        -> final answer

这就是当前版本的两阶段最小闭环:

  • 第一阶段:模型决定是否调用工具
  • 第二阶段:工具执行完成后,再生成最终答案

3. 当前没有可用工具

user message
  -> baseModel
    -> final answer

这里要特别强调一下:

“当前没有可用工具”属于运行时能力状态,不是对用户问题做内容分类。

关键代码:统一模型接入与主执行链

function createBaseModel(request: ChatRequest, deps: ChatServiceDependencies) {
    return 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 baseModel = createBaseModel(request, deps)
const activeTools = getActiveTools()
const toolBoundModel =
    activeTools.length > 0 ? baseModel.bindTools(activeTools.map(toolDefinition => toolDefinition.tool)) : null

if (!toolBoundModel) {
    await streamDirectAnswer(baseModel, langChainMessages, context, writeChunk, () => closed)
    writeChunk({ type: 'finish' })
    return
}

这里的关键不是代码有多复杂,而是这个判断边界很清晰:

  • 有工具可用时,模型具备 tool calling 能力
  • 没有工具可用时,系统仍然可以正常工作

为什么 calculator 仍然值得单独做

很多人会觉得:

“先做一个计算器工具是不是太简单了?”

但从工程角度看,calculator 恰恰很适合做第一版 Tool Calling。

1. 它是确定性工具

输入同一个表达式,输出应该永远一致。

这意味着一旦结果错了,我们更容易判断问题出在:

  • 模型没有正确发起 tool call
  • tool 参数不合法
  • 工具执行失败
  • 工具结果回填后,模型又把答案组织错了

2. 它非常适合验证校验链路

tool_calls 不是模型说什么就执行什么。

在真正执行前,我仍然做了这几步:

  1. 工具存在性检查
  2. 参数归一化
  3. Zod.safeParse
  4. 执行工具

这套链路虽然不复杂,但正是 Tool Calling 从 Demo 走向工程实现时最关键的一步。

3. 它天然暴露“工具结果正确 ≠ 最终答案一定正确”这个问题

这一点是我这次实现里最值得记录的坑之一。

calculator 明明算出了正确结果,但第二阶段模型仍然可能:

  • 重新手算一遍
  • 把中间步骤写错
  • 输出和工具结果冲突的回答

tool3.png

这说明一个很现实的问题:

Tool Calling 的核心难点,不只是“会不会调工具”,还包括“调完工具以后,系统如何保证最终答案仍然可信”。

关键代码:calculator 工具定义

export const calculatorToolSchema = z.object({
    expression: z.string().min(1).max(200),
})

export function normalizeCalculatorExpression(expression: string): string {
    return expression
        .trim()
        .replaceAll('×', '*')
        .replaceAll('÷', '/')
        .replaceAll('(', '(')
        .replaceAll(')', ')')
        .replaceAll(/\s+/g, ' ')
}

export const calculatorTool = tool(
    async ({ expression }) => {
        const normalizedExpression = normalizeCalculatorExpression(expression)
        const result = evaluate(normalizedExpression)
        return String(result)
    },
    {
        name: 'calculator',
        description: '执行数学表达式计算',
        schema: calculatorToolSchema,
    }
)

关键代码:工具注册入口

const calculatorToolDefinition: ChatToolDefinition = {
    name: 'calculator',
    tool: calculatorTool,
    schema: calculatorToolSchema,
    normalizeArgs: normalizeCalculatorToolArgs,
    formatInput: formatCalculatorToolInput,
    resultIsAuthoritative: true,
}

const chatToolDefinitions = [calculatorToolDefinition]

export function getActiveChatToolDefinitions(): ChatToolDefinition[] {
    return chatToolDefinitions.filter(toolDefinition => toolDefinition.isAvailable?.() ?? true)
}

虽然当前只有一个工具,但这个注册入口已经把下一版的扩展位留出来了。

Zod 在这一版里不是配角

如果只看功能演示,很多人会把 Zod 当成附属工具。

但在实际实现里,Zod 是这一版稳定性的关键基础设施之一。

1. 请求输入校验

前端每次发送:

  • conversationId
  • messages[]
  • options

都要先经过请求 schema 校验。

2. Tool Call 参数校验

模型产出的 tool_calls 不是可信输入。

如果不做校验,模型只要给出一个奇怪参数,就可能直接把工具执行链路带崩。

这一版的处理方式是:

  • 先归一化参数
  • safeParse
  • 通过才执行
  • 失败就输出 tool-error

3. 前端流协议校验

前端消费的不是纯文本,而是结构化 NDJSON 事件流。

所以每个 chunk 进入渲染前,也要先经过 schema 校验。

这件事的价值在于:

  • 某一次流式异常不会直接污染整个状态树
  • 协议一旦有问题,更容易定位在“服务端输出错误”还是“前端消费错误”

关键代码:tool call 显式校验

const normalizedArgs = toolDefinition.normalizeArgs ? toolDefinition.normalizeArgs(toolCall.args) : toolCall.args
const parsedArgs = toolDefinition.schema.safeParse(normalizedArgs)

if (!parsedArgs.success) {
    toolErrors.push({
        id: toolCall.id,
        toolName: toolCall.name,
        input: formatToolInput({
            ...toolCall,
            args: normalizedArgs,
        }),
        message: createToolValidationErrorMessage(toolCall, parsedArgs.error),
    })
    continue
}

validatedToolCalls.push({
    ...toolCall,
    args: parsedArgs.data,
})

我后来越来越觉得:

在 Tool Calling 场景里,模型能力很重要,但真正决定系统稳定性的,往往是“模型之外”的校验与兜底。

前端设计:不是在拼字符串,而是在消费事件流

前端这一版我没有接 AI SDK,而是继续保留自定义 useChatStream

原因很简单:

这一版的目标是把 Tool Calling 跑通,而不是再引入一层新的聊天抽象。

useChatStream 真正做的事情

它处理的不是一段“完整文本”,而是一串结构化事件:

  • start
  • reasoning-start / reasoning-delta / reasoning-end
  • tool-start / tool-end / tool-error
  • text-start / text-delta / text-end
  • finish
  • error

这意味着前端不再是“把文本不断 append 到一个字符串里”,而是在做更细粒度的状态消费。

这带来的直接收益

  1. 推理过程可以单独折叠
  2. 工具调用可以单独展示
  3. 工具结果和最终正文可以自然区分
  4. 以后接更多工具时,前端协议层不需要推翻重来

关键代码:消费 NDJSON 流

async function consumeNdjsonStream(stream: ReadableStream<Uint8Array>, onChunk: (chunk: ChatStreamChunk) => void) {
    const reader = stream.getReader()
    const decoder = new TextDecoder()
    let buffer = ''

    while (true) {
        const { done, value } = await reader.read()

        if (done) {
            break
        }

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() ?? ''

        for (const line of lines) {
            const parsedChunk = chatStreamChunkSchema.safeParse(JSON.parse(line))

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

            onChunk(parsedChunk.data)
        }
    }
}

关键代码:按 chunk 更新消息

case 'tool-start': {
    const messageId = activeStreamRef.current.messageId
    if (!messageId) return

    updateMessages(current => appendPart(current, messageId, createToolPart(chunk.partId, chunk.toolName, chunk.input)))
    return
}

case 'text-delta': {
    const messageId = activeStreamRef.current.messageId
    const textPartId = activeStreamRef.current.textPartId

    if (!messageId || textPartId !== chunk.partId) {
        return
    }

    updateMessages(current => appendTextualPartDelta(current, messageId, chunk.partId, 'text', chunk.delta))
    return
}

这类实现让我很明确地感受到一件事:

一旦前端开始消费结构化事件,聊天 UI 就从“渲染字符串”升级成了“渲染系统状态”。

这版最值得记录的几个坑

如果这篇文章只写最终方案,会显得过于平滑。

但真实开发里,v0.0.5 其实踩了不少坑,我觉得这些坑反而很值得记录。

坑 1:工具结果是对的,模型最终回答却可能是错的

例如 calculator 已经算出了正确结果,但模型在第二阶段生成最终答案时,仍然可能:

  • 重复手算
  • 口算出错
  • 输出与工具结果不一致的内容

tool3.png

后来我在运行时加了一个更明确的策略:

  • calculator 这类确定性工具,工具结果具有权威性
  • 在必要时优先直出工具结果对应的最终答案

也就是说:

模型可以组织语言,但不能推翻确定性工具的结果。

坑 2:普通问答的流式体验“不像真流”

早期我把第一阶段写成了非流式调用,结果带来的体验是:

  • 页面先等很久
  • 然后一次性吐出一大块内容

这让我意识到:

真正的流式体验,不能靠“先等模型完整返回,再拼装输出”来模拟。

后来第一阶段也切回了真正的流式消费,普通问题的体验才重新正常。

坑 3:前端一次渲染异常,会污染下一轮请求

某次流式异常后,前端残留了一条空的 assistant placeholder。下一次请求时,它又被带回服务端,最终触发了:

  • messages[].parts 为空
  • 后端 Zod 校验失败
  • 直接返回 400

后来我补了两层兜底:

  • 发送前清理瞬态脏消息
  • 只把有效 text part 组装进请求

这才保证“一次异常不会影响下一次对话”。

坑 4:模型绑定了工具,不代表它一定会乖乖用工具

理论上模型已经绑定了 calculator,但实际运行里,它仍然可能:

  • 先自己推理
  • 输出伪调用文本
  • 在 reasoning 里长篇展开错误步骤

tool2.png

这说明:

Tool Calling 的稳定性,不能只依赖模型自觉。

这也是为什么提示词设计、参数校验、结果策略这些工程细节都很重要。

当前版本已经做到什么程度

v0.0.5 为止,项目已经具备了这些能力:

  • 支持普通问答
  • 支持单工具 calculator
  • 支持 Tool Calling 可视化
  • 支持本地多轮上下文
  • 支持 reasoning / tool / text 三类结构化展示
  • 支持流式错误兜底

但我也明确保留了这些边界:

  • 不做多工具调度
  • 不做完整 Agent loop
  • 不做工具权限系统
  • 不做工具管理后台
  • 不做长期记忆

这不是因为它们不重要,而是因为这一版的主题非常明确:

先把单工具 Tool Calling 的最小工程闭环做对。

我对 v0.0.5 的总结

如果要用一句话概括这一版,我会这么说:

v0.0.5 的重点不是做了一个计算器,而是把当前聊天系统从“只有模型回答”升级成了“模型可以发起 Tool Calls、服务端可以校验并执行工具、前端可以结构化展示结果”的最小闭环。

这一版做完以后,项目其实第一次真正具备了“能力扩展”的基础。

从这个版本往后,再增加新的 Tool,就不再是“重新造一套聊天系统”,而是在现有骨架上自然长出新的能力。

下一步可以怎么做

如果继续往后迭代,我比较关心的是三个方向:

  1. 增加更多 Tool,验证单 Tool 骨架能否自然扩展
  2. 引入上下文窗口或摘要策略,避免多轮上下文过长
  3. 继续完善 Tool Result 策略,让确定性工具和非确定性工具的回答方式更清晰

项目地址

GitHub:

[github.com/HWYD/ai-min…]

如果这篇文章或这个项目对你有帮助,欢迎点个 Star 支持一下。

后续我也会继续按版本节奏,把这个项目一步步迭代下去。

昨天以前首页

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

作者 倾颜
2026年3月23日 18:22

我把本地 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!

❌
❌