痛点分析:长对话场景下的内存泄漏
去年双十一,公司把客服 Chatbot 从轮询架构升级到流式对话,结果凌晨 2 点 PagerDuty 狂响:8 台 32 G 机器在 30 min 内被吃光干净,重启后 10 min 又打满。排查发现,老代码用while True + redis.blpop保持长轮询,每条对话在内存里维护一个Dict[session_id, full_history],用户聊得越久,列表越长,GC 根本来不及回收。再加上为了“实时”把心跳间隔压到 200 ms,无效空转把 CPU 也拖垮。一句话:传统轮询模型在“长连接 + 长上下文”场景下,既扛不住并发,也守不住内存。
技术对比:gRPC vs WebSocket 的吞吐量差异
把轮询砍掉后,我们纠结在“全双工”通道选型:WebSocket 上手快,gRPC Stream 更省序列化开销。用同一套 JMeter 脚本压 5 min,场景是 200 字节文本上行、800 字节 JSON 下行,后端 4 核 8 G Pod 各 20 副本。
WebSocket(STOMP over SockJS)
- 峰值 TPS 4 800
- 平均 RT 62 ms
- CPU 83%
- 内存涨到 5.2 G
gRPC 双向流(protobuf)
- 峰值 TPS 7 100
- 平均 RT 38 ms
- CPU 67%
- 内存稳定在 3.4 G
结论:在密集小包场景下,gRPC 的 HTTP/2 多路复用 + 二进制编码让吞吐直接提升 48%,RT 降 40%,CPU 余量还能再塞业务逻辑。最终线上采用“WebSocket 只留给浏览器端做兼容,App 与内部服务统一 gRPC”的混合策略。
核心实现:异步对话处理器与重试机制
Python 3.11 + FastAPI,我们写了一个无阻塞的对话入口,把 I/O 全部甩给 asyncio 池,关键片段如下(已脱敏):
import asyncio, httpx, tenacity from loguru import logger from fastapi import FastAPI, Request app = FastAPI() @tenacity.retry( stop=tenacity.stop_after_attempt(3), wait=tenacity.wait_exponential(multiplier=1, min=2, max=10), retry=tenacity.retry_if_exception_type( (httpx.ReadTimeout, httpx.ConnectError) ), ) async def call_llm(prompt: str) -> str: """调用火山引擎 LLM,带指数退避重试""" async with httpx.AsyncClient(timeout=15) as cli: r = await cli.post( "https://maas-api.volcengine.com/v1/chat", headers={"Authorization": f"Bearer {TOKEN}"}, json={"model": "doubao-pro-32k", "messages": prompt}, ) r.raise_for_status() return r.json()["choices"][0]["text"] @app.post("/v1/chat") async def chat_handler(req: Request): body = await req.json() uid = body["uid"] text = body["text"] history = await redis.get(f"dlg:{uid}") or [] prompt = format_prompt(history, text) answer = await call_llm(prompt) # 重试逻辑已封装 history = trim_history(history + [text, answer]) # 防止爆炸 await redis.set(f"dlg:{uid}", history, ex=3600) return {"answer": answer}几点小心得:
tenacity比手动try/except简洁,且支持异步。- 历史记录必须做滑动窗口
trim_history,否则 32 k 上下文模型也会把显存吃光。 - 把
redis换成redis.asyncio全程 await,QPS 从 1 k 提到 4 k,基本打满网卡。
架构图:NLU 与对话状态机
下图用 PlantUML 绘制,可直接粘到 plantuml.com 预览:
@startuml !define RECTANGLE class package "Chatbot Core" { [API Gateway] --> [Auth Middleware] [Auth Middleware] --> [Dialogue Manager] [Dialogue Manager] --> [NLU Engine] [Dialogue Manager] --> [State Machine] [State Machine] --> [LLM Adapter] [LLM Adapter] --> [GPU Pool] [Dialogue Manager] --> [Cache Layer] [Cache Layer] --> [Redis Cluster] } package "Observability" { [Prometheus] <-- [Dialogue Manager] : metrics [Grafana] <-- [Prometheus] } package "Client" { [Web/App] <--gRPC/WS--> [API Gateway] } @enduml思路:把“意图识别 + 实体抽取”独立成 NLU 微服务,只负责分类;State Machine 维护会话阶段(问候、导购、下单、售后),阶段决定调用哪条 prompt 模板;LLM Adapter 做模型路由与版本灰度,GPU Pool 统一通过 K8s Extended Resource 暴露,避免每个副本自己占卡。
生产考量:GPU 池化与 JWT 防重放
GPU 资源池化
线下实测 1 张 A10 可并发 8 路 32 k 上下文,但 Kubernetes 默认 GPU 调度是“整卡独占”,利用率不到 30%。我们偷师 Volcano + MIG 方案:- 把 A30 切成 2g.10gb 实例,每实例 5 路并发
- 部署 vLLM 作为推理池,通过 InferenceService CRD 暴露
- 对话管理器按“模型 + 长度”预估显存,调用前向池子申请 slot,用完即还
结果:同等流量下 GPU 节点从 14 台压到 5 台,每月云账单降 62%。
JWT 令牌防重放
对话接口走公网,必须鉴权。我们采用短周期 JWT(有效期 60 s)+ JTI 白名单:- 网关层校验签名与 exp
- 把
jti写入 Redis,TTL 70 s,重复即拒绝 - 用户重新握手刷新令牌,保证重放窗口 < 60 s
压测 1 k TPS 下,平均鉴权延迟 1.3 ms,内存占用可忽略。
避坑指南:三个高频 OOM 误区
对话历史全量塞给 LLM
误区:为了“体验连贯”把 50 轮记录全部拼 prompt。
解决:保留最近 4 轮 + 摘要,摘要要用同模型离线总结,只损失 2% 意图准确率,却省 70% token。未做会话分片
误区:单 Pod 维护 10 k 长连接,Python 对象暴涨。
解决:按uid % shards把连接散到 32 个 Partition,单 Pod 只处理 1 k 连接,内存曲线立刻平整。把异步库当同步用
误区:await call_llm()外面又包一层asyncio.run(),导致 RuntimeError 事件循环嵌套。
解决:FastAPI 已经跑在uvloop,全程async/await即可,千万别手痒再启新循环。
延伸思考:精度与速度的 Trade-off
当业务要求 500 ms 内必须返回首字,而 32 B 模型在 CPU 要 1.2 s,GPU 也要 600 ms,你会怎么选?
- 降级 7 B 模型 + 4-bit 量化,首字 280 ms,但意图准确率掉 6%
- 改流式输出,先让前端“假装”开口,把 TTFB 降到 120 ms,用户体感提升,却增加编排复杂度
- 或者把 NLU 前置,用轻量 BERT 分类,95% 场景走规则,5% 走大模型,整体 RT 降 45%,可维护性又成了新坑
没有银弹,只有“业务容忍误差”与“硬件预算”之间的动态平衡。留一道开放题:如果让你再砍 100 ms,你会先动模型还是动工程?欢迎一起拆招。
写在最后:把实验搬回家
上面这些代码和思路,其实都能在本地笔记本跑通。我在从0打造个人豆包实时通话AI动手实验里,把 ASR→LLM→TTS 整条链路拆成 5 个 Docker 容器,脚本一键拉起,Web 页面直接对话。跟着做完,你会直观看到 GPU 池化、流式输出、JWT 鉴权这些“黑话”是怎么跑起来的。小白也能顺利体验,我实际操作发现很便捷,如果你正好想给自己的项目加上实时语音,不妨去戳链接试试。