前阵子接了个内部工具的需求一台不连外网的服务器上要搭一个 AI 助手数据不能出网在线大模型服务全部被砍掉最后的路只剩一条——把大模型放在本地跑。我折腾了一圈之后最终定下Ollama Vue这套组合Ollama 负责把模型拉起来、提供 HTTP 接口Vue 负责写前端的聊天界面前后端一接一个完全本地化的 AI 助手就成了型。这篇就把整个链路掰开揉碎讲一遍包括接口格式、流式响应、前端实现以及我踩过的几个坑适合准备做本地模型应用、又不想直接套现成后端的开发同学参考。1. 为什么我会在离线环境里硬啃本地模型这条路1.1 本地部署到底图什么先说需求背景。当时要做的内部工具本身不复杂一个网页版的问答助手员工能提问、能看回答、最好还能多轮对话。但卡在环境上——数据不能出局域网任何需要把内容发到外网的方案都不可行所以摆在面前的选择非常少。这种场景下本地模型几乎是唯一答案它的好处是硬性的数据不出本机隐私和安全问题直接消解不用担心提问内容被第三方留存。没有按 token 计费模型在本地跑跑多少轮都是自己的算力。断网可用不依赖外部 API 的可用性服务稳定性自己掌握。参数可控温度、上下文长度、系统提示词都能按业务改不会被平台限制。当然代价也很直白硬件要求自己扛模型效果上限受本地算力约束运维也要自己管。但对于内网工具来说这些代价完全值得。1.2 技术选型Ollama 和 Vue 是怎么凑到一起的先解释一下 Ollama 是什么。它是一个本地大模型运行工具把模型下载、加载、推理、接口服务都封装好了底层用的是 llama.cpp 这类推理框架但对使用者来说你只需要几条命令就能把模型跑起来不用关心量化、显存管理、算子优化这些底层的麻烦事。我为什么没去直接手搓 llama.cpp 或者用其他更底层的方案因为没必要。Ollama 几个特点很对我的痛点一条命令装模型ollama pull qwen2.5:7b模型管理几乎是零成本。自带 HTTP 服务装完直接监听 11434 端口给前端提供了标准接口。支持流式返回聊天场景必须逐字出结果流式接口是刚需Ollama 原生支持。社区模型生态好常见的开源模型都有现成的量化版本按需拉取就行。前端选 Vue 也很自然。聊天界面最大的特点是数据高频变化AI 的回答是一个字一个字蹦出来的Vue 的响应式系统处理这类场景非常顺手界面上绑定一个字符串变量流式数据往里推就自动更新了。再加上 Vite 脚手架建项目速度快组合式 API 写组件逻辑也清爽短期搭一个功能完整的界面完全够用。也有人会问直接用现成的 Web UI 方案不好吗成品确实开箱即用但自己做有两个好处一是能完全贴合业务场景比如接入内部账号体系、定制系统提示词、对接知识库二是这套链路走下来你对模型接口和流式处理的理解会深很多后面想加什么功能都有底。2. 先别写代码Ollama 的安装、拉模型与最快验证2.1 安装和模型准备Ollama 的安装本身没什么难度官网下对应系统的安装包即可Windows、macOS、Linux 都有现成的安装方式。装完打开终端先确认服务能不能跑ollama --version ollama serveollama serve会启动后台服务默认监听127.0.0.1:11434看到监听端口起来了Ollama 这半边就算完成了一半。接下来拉模型。我用的主力是qwen2.5:7b中文效果在 7B 这个参数规模里比较能打显存和内存的占用也相对友好。社区里常见的替代还有llama3.1、mistral等选型逻辑基本看两点中文语料好不好、量化版体积适不适合你的机器。ollama pull qwen2.5:7b ollama listollama list能看到本地已下载的模型列表确认qwen2.5:7b在列就说明模型文件已经就位。模型名称里带q的通常是量化版本数字越小表示量化越激进、体积越小、质量也会有轻微损失。个人服务器如果是 CPU 推理一般选 4bit 量化比较均衡。2.2 装完先别急着写前端用 curl 确认接口能通很多第一次接触 Ollama 的同学直接上手写前端结果发现页面一直报错搞了半天才发现是模型服务本身没通。我的建议是先用 curl 把接口验证一遍把问题边界划清楚。先看模型服务活着没有curl http://127.0.0.1:11434/api/tags正常会返回一个 JSON 数组里面是本地已有的模型信息。然后发一条最简单的生成请求curl http://127.0.0.1:11434/api/chat \ -H Content-Type: application/json \ -d { model: qwen2.5:7b, messages: [{role: user, content: 你好简单介绍一下你自己}], stream: false }响应里会有message.content字段里面就是模型生成的回答。这里把stream设为false是为了看整体返回结构等到写代码的时候再改成true走流式。这一套验证下来环境层面基本就没问题了。之后的问题基本都集中在两个地方接口调用的姿势、前端的流式处理这俩才是真正花时间的部分。3. 吃透 Ollama 的 HTTP 接口前后端链路才不会翻车3.1 /api/generate 和 /api/chat什么时候用哪个Ollama 提供了两个常用接口/api/generate和/api/chat。名字上看一个偏向文本生成、一个偏向对话但在实际使用里它们的边界很容易被忽略。/api/generate接口的请求体是一个纯文本的prompt字段模型把它当成补全任务来生成。适合的场景是文本总结、润色、代码补全这类单次生成任务不需要明确区分用户和助手角色。/api/chat接口的请求体是messages数组数组里每个元素带有role和content你可以把多轮对话历史一起传过去模型会根据上下文来回答。做聊天助手基本都用这个因为对话场景最关键的是保持上下文连贯。我用一个简单的对比表来说明对比项/api/generate/api/chat核心参数promptmessages[]适用场景单次补全、总结、改写多轮对话、聊天助手上下文组织手动拼进 prompt按角色消息列表传入流式支持支持支持很多初学者在聊天功能里用 generate 把历史对话手动拼成一段超长 prompt也能跑但角色语义会丢失模型表现不如 chat 接口。我的建议是做聊天助手就老老实实用/api/chat少给自己埋坑。3.2 流式响应的数据结构和解析原理聊天场景对响应速度非常敏感一个回答如果等全部生成完再展示体验会非常糟糕。所以 Ollama 默认推荐使用流式响应即stream: true。这时候接口返回的不是一个完整 JSON而是一串按行分隔的 JSON 对象术语叫 NDJSON。每一行的结构长这样{model:qwen2.5:7b,created_at:2024-01-01T12:00:00.000000000Z,message:{role:assistant,content:你},done:false} {model:qwen2.5:7b,created_at:2024-01-01T12:00:00.000000000Z,message:{role:assistant,content:好},done:false} {model:qwen2.5:7b,created_at:2024-01-01T12:00:00.000000000Z,message:{role:assistant,content:},done:false} {model:qwen2.5:7b,created_at:2024-01-01T12:00:00.000000000Z,message:{role:assistant,content:},done:true,done_reason:stop}每行 JSON 里的message.content是本次返回的一小段文本done字段标记流是否结束。当done为true时这一行是终止标记同时会有done_reason说明结束原因常见的是stop表示自然结束。前端拿到这个流之后要做的就是把每一行的content段拿出来按顺序追加到当前助手消息里。核心的解析方式其实不在 JSON 本身而在怎么把一个字节流按行切开这块后面讲 Vue 实现时我会给出具体代码。3.3 CORS 和网络配置前端直接请求还是自己架代理开发阶段我在浏览器里直接 fetchhttp://127.0.0.1:11434/api/chat结果被跨域问题拦住了。Ollama 默认允许跨域的能力有限浏览器从localhost:5173的页面请求localhost:11434会被 CORS 预检查挡掉。这个问题有几种解法设置环境变量放行在启动 Ollama 前设置OLLAMA_ORIGINS*或者在系统环境变量里配置OLLAMA_HOST0.0.0.0作用是放开跨域和网卡监听范围。适合开发环境快速验证但生产环境不建议全放开。让前端只访问同源地址由后端代理转发前端页面请求自己的后端服务后端再转发到 Ollama 的 11434 端口。跨域问题彻底消失还能在转发层做鉴权、限流、日志记录。实际项目里我始终推荐第二种。原因很简单Ollama 接口本身没有鉴权如果服务器暴露在不可信网络里任何能访问到端口的人都能调用模型这会造成算力滥用和潜在的数据泄露。走一层内部代理你能把模型调用收敛在有控制的地方。4. Vue 这一侧消息数据结构与流式读取的完整实现4.1 工程结构和核心状态设计前端部分我用 Vite 初始化了一个 Vue 3 项目npm create vitelatest ai-assistant -- --template vue cd ai-assistant npm install项目结构里核心组件是两个ChatWindow.vue负责整体消息展示和输入框MessageList.vue负责渲染每一条消息。状态管理直接用 Vue 的组合式 API一个messages数组就能撑起整个对话界面还没到需要引入 Pinia 的复杂度。消息对象的结构我设计成这样// 用户消息 { id: 1, role: user, content: 你好 } // 助手消息content 会随着流式返回逐步增长 { id: 2, role: assistant, content: , loading: true }id用Date.now()生成即可loading字段用来标记助手消息是否还在生成中前端 UI 可以在生成过程中展示一个光标动画也方便在出错时做状态恢复。4.2 发送消息与流式读取的完整过程发送一条消息前端要做的其实是三件事把用户消息推到数组里、立刻补一条空的助手消息、然后发起请求并把流式数据追加到那条助手消息上。下面这段代码是核心逻辑script setup import { ref } from vue const messages ref([]) const input ref() const isGenerating ref(false) async function sendMessage() { const text input.value.trim() if (!text || isGenerating.value) return // 推送用户消息和空的助手消息 messages.value.push({ id: Date.now(), role: user, content: text }) const assistantMsg { id: Date.now() 1, role: assistant, content: , loading: true } messages.value.push(assistantMsg) input.value isGenerating.value true try { // 组装请求体多轮对话直接把历史 messages 全部带过去 const history messages.value .filter(m m.content.length 0) .map(m ({ role: m.role, content: m.content })) const response await fetch(/proxy/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: qwen2.5:7b, messages: history, stream: true }) }) // 重点用 ReadableStream 逐段读取 const reader response.body.getReader() const decoder new TextDecoder() let buffer while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) // 按行切分 NDJSON const lines buffer.split(\n) buffer lines.pop() || for (const line of lines) { if (!line.trim()) continue const json JSON.parse(line) if (!json.done json.message?.content) { assistantMsg.content json.message.content } } } } finally { assistantMsg.loading false isGenerating.value false } } /script这段代码里有几个细节值得单独说。第一用TextDecoder对字节流做解码是因为reader.read()拿到的value是Uint8Array不能直接当字符串用。同时要带上{ stream: true }参数避免多字节字符比如中文恰好被拆分到两次读取里导致乱码。第二按行切分时用buffer.split(\n)把最后一段残留存回buffer。这是因为一次网络读取可能包含多个 JSON 行也可能只包含半个 JSON 行必须做缓冲处理才能保证每个 JSON 对象都是完整的。第三流式数据的追加用的是assistantMsg.content json.message.content这个对象本身已经在messages数组里了Vue 的响应式系统会感知到内容变化并自动更新界面不需要手动操作 DOM。这也是用 Vue 写这类功能最舒服的地方。4.3 流式渲染为什么会卡性能和展示取舍第一次跑通流式的时候我发现一个奇怪现象模型输出速度很快但界面上文字是跳着出现的甚至有时候整个页面卡顿。后来复盘发现原因有两个一是渲染频率过高。模型每次返回一个 token 就触发一次 Vue 响应式更新如果页面里还有 markdown 渲染、代码高亮这些重活性能就扛不住了。解决思路是批量通知不要每个 token 都触发完整渲染而是累积一小段再更新。比如维护一个chunkBuffer每收集到一段就统一content chunkBuffer。二是重渲染范围过大。聊天界面里如果所有消息都绑定同一个响应式状态任何一条变化都会触发整个列表重新渲染。实际项目里建议给每条消息加一个v-memo或者在组件层面上做拆分让每次变化只重新渲染正在变化的那一条。展示层面还要注意 HTML 注入问题。模型输出的内容是纯文本如果在模板里直接拿v-html渲染遇到特殊内容可能被浏览器解析成 HTML 标签轻则样式错乱重则产生 XSS 风险。稳妥的做法是先以纯文本展示完整流程真要支持 markdown 和高亮必须引入专门的 sanitizer 库清洗内容之后再做渲染。我的实现里第一步就是纯文本版跑通之后再按需加 markdown 支持。5. 流式渲染和上下文实战里最折腾人的两个地方5.1 上下文越长生成越慢也越吃内存多轮对话跑到第五六轮之后我发现模型响应速度肉眼可见地变慢。原因不复杂/api/chat接口默认把整个messages数组里的全部内容都算进上下文里对话越长模型每次生成前需要回顾的 token 就越多耗时和资源占用都会上涨。举例来说如果历史对话累积到了 5000 个 token那么用户每问一句模型都要基于这 5000 多个 token 进行处理。不仅首字延迟变高显存占用也跟着上去。对于 7B 模型来说上下文窗口默认值通常不大超出之后还会出现模型忘记早期对话的情况。处理策略我用的是组合拳滑动窗口只保留最近 N 条消息传给模型太早的对话直接丢弃。超长对话摘要如果业务上必须保留完整上下文就定期调用模型把旧对话压缩成一段摘要下次请求时把摘要 最近几条消息一起传给模型。// 伪代码只取最近 20 条消息作为上下文 const contextMessages messages.value.slice(-20)这个数字不是死板的具体取多大依赖你的模型量化版本和机器内存。我在 7B 模型上测试下来最近 20~30 轮对话是体验和资源占用之间比较均衡的点。5.2 冷启动、并发请求和超时处理还有三个实际运行中肯定会遇到的现象提前说清楚能少走弯路。第一个是冷启动。模型文件第一次被加载进内存需要时间冷启动后第一轮请求会明显偏慢几十秒是正常的。等模型常驻内存后后续请求就快了。Ollama 允许通过环境变量控制模型的空闲卸载策略如果你的服务器内存够大可以调大OLLAMA_KEEP_ALIVE让模型在内存里待得更久减少反复加载的开销。第二个是并发请求。Ollama 对同一模型默认是串行处理的多个用户同时问问题时后到的请求会自动排队。前端层面需要做防重处理按钮置灰、禁止重复发送、显示生成中状态避免用户在等待时连点导致请求堆叠。第三个是超时和错误处理。外网 API 的失败处理经验在这里同样适用。常见错误有两类模型不存在HTTP 400和请求被取消连接中断。前端要用try...catch...finally把请求包装好出错时给用户明确提示同时把loading状态复位避免界面卡在生成中。5.3 推理参数该怎么调几个常用参数的实际体验Ollama 的请求体里可以带一个options对象里面是推理参数。我踩过参数调乱导致输出质量崩掉的坑这里整理一份常用参数的实测经验参数作用我的推荐值temperature控制随机性越高越发散0.7 ~ 0.8问答场景用 0.7top_p核采样减少低概率词0.9 左右num_predict单次生成最大 token 数看场景问答 512写代码 2048num_ctx上下文窗口长度4096 起步内存够再往上加注意num_ctx和显存直接相关不能一味调大。我在增大上下文长度后遇到过显存溢出表现是接口直接报错当时的处理是调小num_ctx同时保证历史消息不要撑爆窗口。温度参数也要小心。很多人觉得温度越高越聪明实际恰恰相反温度过高会让回答变得跳脱、逻辑不稳定尤其是在知识问答和代码生成类任务里低温度表现更可靠。6. 从可用到好用还能继续接的东西和优化思路基础版的聊天助手做出来之后再往下走有几个方向我自己试过效果不错给想继续深入的同学做个参考。接私域知识库让模型回答企业自己的内容。本地模型在通用知识上够用但对内部文档、产品手册这类专属内容一无所知。常规做法是 RAG检索增强生成先把文档切片并向量化用户提问时先检索最相关的片段再把片段和问题一起拼进 prompt 附近。Ollama 这边可以用nomic-embed-text之类的 embedding 模型做向量化数据同样是本地处理不破数据不出网的底线。接入账号体系和对话记录存储。如果工具要给多人用每个用户的对话历史应该隔离存储。我自己的做法是把对话记录落到本地数据库每次重新加载页面时恢复历史列表这样既保留了上下文也避免服务重启后数据全丢。系统提示词做角色定制。这一条成本最低收益却很明显。Ollama 的 chat 请求支持在messages开头放一条system角色消息定义助手的身份、语气、回答边界。比如让模型扮演内部技术支持、客服助手、代码审查员效果相当于给模型加了一层行为约束对落地场景非常实用。语音输入和朗读输出。本地部署的语音方案也已经很成熟前端接上语音识别和 TTS就能把一个文本聊天工具变成真正的语音助理这在会议室、前台、展厅这类场景里挺出效果。我自己实际跑下来的感受是Ollama 把本地模型的部署门槛降得非常低真正的功夫全在周边工程上——接口的设计、上下文的维护、前端的流式处理、异常情况的兜底。这套链路一旦跑通后续往任意业务场景扩展核心骨架都不需要大改。如果你正准备在隔离环境里做一个 AI 工具我的建议很简单先照着这篇文章把最小闭环跑起来再根据你实际的需求往上加东西别一上来就想做到面面俱到。