前阵子接了个内部工具的需求:一台不连外网的服务器上要搭一个 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 |
|---|---|---|
| 核心参数 | prompt | messages[] |
| 适用场景 | 单次补全、总结、改写 | 多轮对话、聊天助手 |
| 上下文组织 | 手动拼进 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_HOST=0.0.0.0,作用是放开跨域和网卡监听范围。适合开发环境快速验证,但生产环境不建议全放开。 - 让前端只访问同源地址,由后端代理转发:前端页面请求自己的后端服务,后端再转发到 Ollama 的 11434 端口。跨域问题彻底消失,还能在转发层做鉴权、限流、日志记录。
实际项目里我始终推荐第二种。原因很简单:Ollama 接口本身没有鉴权,如果服务器暴露在不可信网络里,任何能访问到端口的人都能调用模型,这会造成算力滥用和潜在的数据泄露。走一层内部代理,你能把模型调用收敛在有控制的地方。
4. Vue 这一侧:消息数据结构与流式读取的完整实现
4.1 工程结构和核心状态设计
前端部分我用 Vite 初始化了一个 Vue 3 项目:
npm create vite@latest 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.7 |
| top_p | 核采样,减少低概率词 | 0.9 左右 |
| num_predict | 单次生成最大 token 数 | 看场景,问答 512,写代码 2048 |
| num_ctx | 上下文窗口长度 | 4096 起步,内存够再往上加 |
注意num_ctx和显存直接相关,不能一味调大。我在增大上下文长度后遇到过显存溢出,表现是接口直接报错,当时的处理是调小num_ctx,同时保证历史消息不要撑爆窗口。
温度参数也要小心。很多人觉得温度越高越"聪明",实际恰恰相反,温度过高会让回答变得跳脱、逻辑不稳定,尤其是在知识问答和代码生成类任务里,低温度表现更可靠。
6. 从可用到好用:还能继续接的东西和优化思路
基础版的聊天助手做出来之后,再往下走有几个方向我自己试过,效果不错,给想继续深入的同学做个参考。
接私域知识库,让模型回答企业自己的内容。本地模型在通用知识上够用,但对内部文档、产品手册这类专属内容一无所知。常规做法是 RAG(检索增强生成):先把文档切片并向量化,用户提问时先检索最相关的片段,再把片段和问题一起拼进 prompt 附近。Ollama 这边可以用nomic-embed-text之类的 embedding 模型做向量化,数据同样是本地处理,不破"数据不出网"的底线。
接入账号体系和对话记录存储。如果工具要给多人用,每个用户的对话历史应该隔离存储。我自己的做法是把对话记录落到本地数据库,每次重新加载页面时恢复历史列表,这样既保留了上下文,也避免服务重启后数据全丢。
系统提示词做角色定制。这一条成本最低,收益却很明显。Ollama 的 chat 请求支持在messages开头放一条system角色消息,定义助手的身份、语气、回答边界。比如让模型扮演内部技术支持、客服助手、代码审查员,效果相当于给模型加了一层行为约束,对落地场景非常实用。
语音输入和朗读输出。本地部署的语音方案也已经很成熟,前端接上语音识别和 TTS,就能把一个文本聊天工具变成真正的"语音助理",这在会议室、前台、展厅这类场景里挺出效果。
我自己实际跑下来的感受是:Ollama 把本地模型的部署门槛降得非常低,真正的功夫全在周边工程上——接口的设计、上下文的维护、前端的流式处理、异常情况的兜底。这套链路一旦跑通,后续往任意业务场景扩展,核心骨架都不需要大改。如果你正准备在隔离环境里做一个 AI 工具,我的建议很简单:先照着这篇文章把最小闭环跑起来,再根据你实际的需求往上加东西,别一上来就想做到面面俱到。