背景痛点:规则引擎的“三座大山”
过去三年,我维护过两套传统客服系统:一套基于正则+关键词,一套基于 DSL 规则树。它们能跑,但越跑越沉:
- 冷启动像“搬砖”——新业务上线前,运营同学要穷举 200+ 种问法,写 500 条规则,耗时 2 周;一旦产品文案微调,规则又得重测。
- 多轮对话“失忆”——规则引擎没有上下文槽位,用户改个手机号,系统就把前面填的订单号全丢,只能重头来过,体验负分。
- 意图漂移“背锅”——“我要退款”和“我要退货”只差一个字,规则却当成两条分支;活动高峰期并发一高,正则回溯直接把 CPU 打满,客服同学背锅,研发同学通宵。
痛定思痛,我们决定把大模型搬进来,但 ChatGPT 按量计费+数据出境审计,老板直接否。于是盯上了本地可跑的 Ollama:免费、可微调、GPU 自己管,完美契合“既要省钱又要安全”的 KPI。
技术选型:Ollama 不是银弹,却是最趁手的那把刀
| 维度 | Ollama (本地) | 商业 API (云端) | |----| | --- | | 成本 | GPU 一次性投入,后续仅电费 | 按 Token 计费,高并发≈高账单 | | 数据隐私 | 数据不出内网,过等保轻松 | 需额外签 DPA,跨境同步麻烦 | | 定制化 | 可 LoRA 微调,领域词汇即插即用 | 微调门槛高,周期长 | | 延迟 | 800 ms 内(下文会讲优化) | 网络波动,国内 1.2 s 起步 | | 运维 | 自己管卡、管驱动、管容灾 | 0 运维, SLA 9 个 9 |
一句话:如果公司已有闲置 3090/4090,Ollama 就是“花 1 张显卡钱,省 10 张发票钱”的买卖。
核心实现:RAG+流式+状态机三板斧
1. 用 LangChain 搭 RAG,知识库 30 分钟上线
先准备语料:把旧 FAQ、工单、Confluence 页面统一导成 Markdown,按“问题-答案”对拆条,共 1.8 万条。接着走以下流水线:
- 文本切片 → 512 token/段,重叠 50 token,避免表格被拦腰斩断
- 向量化 → sentence-transformers/all-mpnet-base-v2,768 维,GPU 批量编码 800 条/s
- 存库 → Qdrant 本地 Docker 版,collection 名
kb_v1,距离算法Cosine - 检索 → top_k=5,score_threshold=0.78,LangChain 的
EnsembleRetriever把稀疏 BM25 与稠密向量做加权,准确率提升 8%
关键代码(已 PEP8,含类型标注):
# rag_pipeline.py from typing import List from langchain.vectorstores import Qdrant from langchain.schema import Document class RagService: def __init__(self, collection: str = "kb_v1"): self.store = QdrantClient(host="localhost", port=6333) self.collection = collection def retrieve(self, query: str, top_k: int = 5) -> List[Document]: hits = self.store.search( collection_name=self.collection, query_vector=self._embed(query), limit=top_k, score_threshold=0.78 ) return [Document(page_content=h.payload["text"], metadata=h.payload["meta"]) for h in hits] def _embed(self, text: str) -> List[float]: # 省略编码逻辑 ...2. FastAPI 异步流式响应,SSE 一行配置
大模型最怕“用户干等”。我们采用 Server-Sent Events(SSE)把首 Token 时间压缩到 300 ms 以内。要点:
- 路由必须
async,Ollama 的/api/generate支持stream=true - 用
asyncio.Queue解耦“模型推理”与“HTTP 推送”,避免阻塞 - 前端 EventSource 只接收
data:行,方便做 JSON.parse 后直接渲染
核心片段:
# main.py from fastapi import FastAPI, Request from sse_starlette.sse import EventSourceResponse import aiohttp, json app = FastAPI() async def ollama_stream(prompt: str): url = "http://localhost:11434/api/generate" payload = { "model": "llama3:8b-instruct-q4_K_M", "prompt": prompt, "stream": True } async with aiohttp.ClientSession() as session: async with session.post(url, json=payload) as resp: async for line in resp.content: if not line: continue body = json.loads(line) yield {"data": body.get("response", "")} @app.post("/chat") async def chat(request: Request): body = await request.json() prompt = build_rag_prompt(body["message"]) # 拼接检索结果 return EventSourceResponse(ollama_stream(prompt))3. 对话状态机,幂等性防重放
客服场景常遇到“用户狂点发送”或“网关重试”,我们给每条消息生成 UUID,状态机以<session_id, uuid>为键,保证同一条请求多次进来只执行一次推理。状态机用 Redis Hash 存储:
- key:
chat:{session_id} - field:
uuid - value: 序列化后的
DialogTurn(含意图、槽位、时间戳)
幂等判断伪代码:
def is_duplicate(session_id: str, uuid: str) -> bool: return redis.hexists(f"chat:{session_id}", uuid)若已存在,直接返回缓存的 SSE 事件,节省一次 GPU 推理。
性能优化:vLLM+Locust 双管齐下
1. vLLM 加速,显存占满才是真的省
Ollama 0.2 起支持 vLLM 后端,实测在 4090 24 G 上:
- 原生 ggml:并发 5 qps,显存 18 G,P99 1.8 s
- vLLM:并发 20 qps,显存 22 G,P99 0.6 s
配置只需改/etc/ollama/config.json:
{ "backend": "vllm", "max_num_seqs": 256, "gpu_memory_utilization": 0.95, "dtype": "float16" }注意gpu_memory_utilization别调 1.0,留 5 % 给 CUDA kernel,否则显存碎片会报 OOM。
2. Locust 压测脚本,10 分钟复现峰值
我们按“上午 10 点促销”历史流量建模:5 分钟爬升到 100 并发,持续 10 分钟。Locust 脚本核心:
from locust import HttpUser, task, between class ChatUser(HttpUser): wait_time = between(1, 3) @task def ask(self): self.client.post("/chat", json={"message": "如何申请退款?"})跑完看面板:若 P95 > 1 s,就回退max_num_seqs;若 GPU 利用率 < 60 %,则上调并发。三回合即可找到最佳甜点。
避坑指南:那些凌晨 3 点的血泪
热加载内存泄漏
Ollama 早期版ollama pull新模型后,旧权重仍占显存;重复拉取 5 次,24 G 卡直接爆。解决:升级到 0.1.41+,并在 CI 里加ollama rm old-model步骤,确保“拉新删旧”。对话历史 token 超限
llama3 8 K 上下文看似富裕,实际 RAG 拼接后 3 轮就破表。我们采用“滑动窗口 + 摘要”双保险:- 窗口保留最近 2 轮
- 更早的轮次用 LangChain 的
ConversationSummaryBufferMemory做摘要,token 节省 60 %,答案质量无明显下降。
前后端 SSE 断流
Nginx 默认 60 s 切断空闲连接,前端还没收完就 502。在location /chat加:proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s;让长连接活到自然死亡。
代码规范:把“能跑”写成“可维护”
- 所有函数写类型标注,启用
mypy --strict,拒绝Any - 捕获 Ollama 的
aiohttp.ClientPayloadError,返回 503 并带Retry-After - 日志统一用
structlog,字段包括session_id,uuid,latency_ms,方便 Kibana 绘图
延伸思考:WebSocket 双向通信,值得吗?
SSE 是“服务器→单工”,若要做“用户输入中断”“客服主动推送”,WebSocket 更自然。实测同样 100 并发:
- SSE:P99 0.6 s,CPU 占用 28 %
- WebSocket:P99 0.55 s,CPU 占用 35 %,但心跳包让带宽多 8 %
建议先跑 A/B:同一页面 50 % 用户走 WebSocket,50 % 走 SSE,核心指标看“首字时间”与“客服介入率”。两周后若 WebSocket 介入率下降 5 % 以上,再全量切过去。
写在最后的用户视角
整套系统上线两个月,意图识别准确率从 78 % 提到 92 %,平均响应 800 ms,GPU 电费每月 300 块,比原来调商业 API 省了 4 万。最开心的是客服小姐姐:以前活动高峰 2000 排队,现在 90 % 的咨询被 AI 接住,她们终于能准时下班。对我而言,最大的收获是发现 Ollama 不是“玩具”,只要肯在 RAG、状态机、性能上各做 20 % 的功课,就能换来 80 % 的效果。下一版,我们打算把语音转文字也接进来,让模型直接“听”用户抱怨——到时候再来分享踩坑日记。