ChatGLM本地部署API化:显存、流式输出与并发实战

发布时间:2026/10/25 4:05:16
ChatGLM本地部署API化:显存、流式输出与并发实战

本地把模型跑起来的那一刻通常是最爽的,python demo.py一敲,终端里蹦出第一句回复,感觉这事儿成了。但接下来往往是另一回事:产品同事要接、前端要联调、测试要压并发、业务方要按接口计费口径对数——你不可能让每个人都装一遍 CUDA、装一遍 PyTorch、再背一遍模型路径。这时候真正要做的事情就浮出水面了:对本地部署的 ChatGLM 模型进行 API 调用,也就是把那个躺在显卡里的模型,包装成一个标准的、能在内网被任意语言调用的 HTTP 服务。这篇内容我围绕这条主线讲透:从为什么值得包一层、显存账怎么算、服务怎么起、流式怎么接、并发怎么扛,到那些只有真跑过才会遇到的坑。不管你是刚把模型跑通想往前一步,还是已经在写服务但被 OOM 和断流折磨过,下面这些应该都能直接抄。

1. 包一层 API 之前,先想清楚要解决什么问题

1.1 直连模型脚本的三个真实痛点

大部分人的起点是一个demo.py:加载 tokenizer、加载 model、写个 while 循环读 input、调model.chat()打印结果。这个形态在验证阶段非常合适,但只要往生产靠近一步,立刻会撞上三堵墙。

第一堵墙是进程模型不对。模型加载一次要几十秒甚至几分钟,占掉十几 GB 显存,而你的业务请求是零零散散来的。如果每个请求都重新加载模型,等于每次都在做冷启动;如果用一个长驻进程读 stdin,那这个进程和业务系统之间就没有任何标准协议,只能靠管道硬怼。第二堵墙是并发语义缺失。单进程单请求,第二个人点一下按钮,只能排队等第一个人把 500 个 token 吐完。第三堵墙是客户端绑定。前端是 JS、数据侧是 Java、脚本侧是 Python,你不能要求所有调用方都装 transformers。

所以包 API 这件事,本质上是把"模型推理"从"一个脚本"变成"一个服务":服务持有模型常驻显存,对外暴露 HTTP 接口,请求过来排队进显存,结果按协议返回。它解决的不是"模型能不能跑",而是"模型能不能被别人用"。

1.2 三种调用形态的取舍:直调、WebUI、自建 API

同样是"对外提供能力",常见有三条路,选错了后面返工成本很高。我把它们的差异摊开说。

形态典型做法上手成本可控性适用阶段
脚本直调transformers +model.chat()极低高但没法共享单人验证、离线批处理
现成 WebUI官方或社区的对话界面低中,业务逻辑难插入内部演示、非技术人员试用
自建 API 服务FastAPI/Flask 包一层,走 HTTP中最高,可加鉴权、日志、限流业务集成、多端复用
OpenAI 兼容层在自建服务上加/v1/chat/completions中高,且生态工具直接复用想低成本迁移已有调用方

我的建议很直接:如果你只是自己玩,脚本直调足够;只要有一个外部调用方,就上自建 API;如果调用方已经在用 OpenAI 风格的 SDK,那就在自建 API 上再叠一层兼容协议。兼容层这一步的性价比极高,因为大量现成的客户端库、评测脚本、Agent 框架默认都按/v1/chat/completions说话,你把这层壳补上,等于零成本接入整个生态。代价是要处理一些字段语义的细微差别,下面会专门讲。

1.3 选型背后的两个关键决定

第一个决定是框架用 FastAPI 还是 Flask。我倾向 FastAPI,原因不是它更时髦,而是三件具体的事:一是原生async,流式输出(SSE)写起来自然,不用手搓生成器挂载;二是自带 Pydantic 校验,请求体字段类型错了直接 422,省掉一堆手写 if;三是自动生成接口文档,联调时前端自己去看,能省掉你一半的沟通时间。Flask 当然也能做,同步模型下差异不大,但流式那块会别扭一些。

第二个决定是推理后端用原生 transformers 还是更快的方案。原生 transformers 的好处是稳、依赖少、跟模型仓库的示例代码一致,出了问题好查;坏处是吞吐一般,尤其是没做批处理的时候。如果你只是内部工具、QPS 个位数,原生完全够用,别过早优化。真到了需要扛并发,再考虑换 vLLM 之类的推理引擎,但那意味着量化格式、模型结构支持、接口形态都要重新对一遍,属于另一条工程路线。

2. 动手之前,先把显存和依赖这两笔账算清楚

2.1 依赖清单与那些"版本不对就报错"的坑

先给一份能跑通的最小依赖集合,我按重要性排:

# 基础推理 pip install torch --index-url https://download.pytorch.org/whl/cu121 pip install transformers accelerate sentencepiece cpm_kernels # 服务层 pip install fastapi uvicorn pydantic sse-starlette # 客户端测试 pip install requests openai

这里有几个坑值得单独拎出来说。transformers的版本要跟模型对得上,太老的版本不认识新的模型结构,症状是加载时报一堆KeyError或者干脆把权重当随机初始化,输出全是乱码;太新的版本有时候又会改动默认的attn_implementation,在你那张老卡上跑出莫名其妙的报错。我的做法是先在干净环境里按模型仓库 README 装一遍、跑通demo.py,把这个版本组合记下来,再往服务环境里搬。accelerate不是可选项,device_map="auto"靠它工作,不装的话多卡和 CPU offload 都不会生效。

还有一个容易忽略的:torch的 CUDA 版本要和驱动匹配。nvidia-smi右上角显示的 CUDA Version 是驱动支持的上限,装超过它的运行时会直接CUDA error: no kernel image is available。这个报错看着像硬件坏了,其实只是版本错配。

注意:不要在同一个环境里同时装 CPU 版和 GPU 版 torch,pip 会保留后装的那个,但残留的库文件会导致一些诡异的undefined symbol错误。发现不对就干净重装。

2.2 显存怎么估:权重 + KV Cache + 中间激活

显存估算这件事,网上一堆"6B 大概 13G"的说法,但它没说清是在什么上下文长度、什么批大小下。我把账拆成三块,你自己代入参数算,比背结论有用。

第一块是权重,公式很朴素:参数量 × 每参数字节数。fp16 是 2 字节,int8 是 1 字节,int4 是 0.5 字节左右(实际有量化元数据开销,略高于理论值)。

第二块是 KV Cache,这是很多人算漏的部分,也是长上下文下爆显存的主因。估算式:

KV Cache ≈ 2 × 层数 × KV头数 × 头维度 × 序列长度 × 批大小 × 字节数

以 6B 级别、28 层、GQA 2 组、头维度 128 的模型为例,单 token 的 KV 占用约 2×28×2×128×2 ≈ 28KB;跑到 8K 上下文、batch 4,就是 28KB × 8192 × 4 ≈ 900MB。看着不大,但如果你的模型没做 GQA(每层头数等于 KV 头数),这个数字会翻十几倍,8K 上下文单条就能吃掉好几 GB。

第三块是中间激活和框架开销,这块最没法定量,我的经验值是留 1.5~2GB 的余量,长上下文时留 3GB。

合起来给一张常见档位的参考表(单卡、8K 上下文、batch 1):

模型规模精度权重大致占用加上 KV 与开销落地的卡
6Bfp1612~13GB14~16GB24GB 卡舒适
6Bint86.5~7.5GB9~11GB16GB 卡可跑
6Bint44~5GB6~8GB12GB 卡勉强
9Bfp1618~19GB21~24GB24GB 卡吃紧
9Bint49~10GB12~14GB16GB 卡可跑

这张表只能当起点用。真正上线前一定要做一件事:用你的真实最大上下文和最大并发去压一遍,看nvidia-smi的峰值,而不是看空闲时的占用。空闲占用好看不代表扛得住,KV Cache 是随请求长出来的。

2.3 模型目录结构和加载参数怎么定

模型下载下来一般是一个目录,里面有config.json、tokenizer.model或tokenizer.json、若干.safetensors或.bin分片。加载时我习惯这样写:

import torch from transformers import AutoTokenizer, AutoModel MODEL_PATH = "/data/models/chatglm-local" tokenizer = AutoTokenizer.from_pretrained( MODEL_PATH, trust_remote_code=True, ) model = AutoModel.from_pretrained( MODEL_PATH, trust_remote_code=True, torch_dtype=torch.float16, device_map="auto", low_cpu_mem_usage=True, ).eval()

几个参数值得解释。trust_remote_code=True是因为这类模型仓库里带了自定义的建模文件,不开这个开关加载器会拒绝执行。安全上要留意:这个开关等于允许执行仓库里的 Python 代码,所以模型来源必须是可信渠道,别随手从网上拖一个来路不明的权重包。torch_dtype显式指定成 fp16,避免某些环境下默认按 fp32 加载,显存直接翻倍。low_cpu_mem_usage=True让加载过程分片进行,能把加载时的内存峰值压低不少,机器内存紧张时是救命参数。.eval()别忘,虽然推理时影响不大,但 dropout 没关会带来输出不稳定。

3. 从零搭一个能用的 API 服务

3.1 最小可用版本:一个文件跑起来

先上一个能跑的最小版本,把它当作骨架,后面所有扩展都往这个骨架上长。

# server.py import time import uuid from typing import List, Optional import torch from fastapi import FastAPI from pydantic import BaseModel, Field from transformers import AutoTokenizer, AutoModel MODEL_PATH = "/data/models/chatglm-local" app = FastAPI(title="local-llm-api") tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True) model = AutoModel.from_pretrained( MODEL_PATH, trust_remote_code=True, torch_dtype=torch.float16, device_map="auto", ).eval() class Message(BaseModel): role: str content: str class ChatRequest(BaseModel): messages: List[Message] temperature: float = Field(0.8, ge=0.0, le=1.5) top_p: float = Field(0.8, gt=0.0, le=1.0) max_new_tokens: int = Field(512, ge=1, le=4096) @app.post("/v1/chat") def chat(req: ChatRequest): history = [] # 最后一条作为当前提问,前面的作为多轮历史 for m in req.messages[:-1]: history.append((m.content, "")) # 简化处理,见 3.2 query = req.messages[-1].content t0 = time.time() response, new_history = model.chat( tokenizer, query, history=history, temperature=req.temperature, top_p=req.top_p, max_length=req.max_new_tokens, ) return { "id": f"chatcmpl-{uuid.uuid4().hex[:12]}", "content": response, "latency_ms": int((time.time() - t0) * 1000), } if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)

这里有个关键点必须说清楚:uvicorn.run(app, ...)里workers千万别设成大于 1。多个 worker 就是多个进程,每个进程都会独立加载一份模型,显存直接乘倍数,第二份直接 OOM。想提高并发只能靠服务内部排队、批处理,或者上多卡,绝不是加 worker。

启动:

python server.py # 或者用命令行形式,方便挂后台 uvicorn server:app --host 0.0.0.0 --port 8000 --timeout-keep-alive 75

--timeout-keep-alive这个参数在流式场景下很重要,默认 5 秒,长时间不吐字的推理会被中间层掐断,症状是客户端收到半截回复就断了。

3.2 多轮对话的拼接:history 到底怎么传

这是新手最容易做错的地方。模型的chat()接口吃的是history列表,元素是(用户问, 模型答)的元组,它不会自动帮你从 messages 数组里还原。如果你按 OpenAI 的 messages 格式一把塞进去,最常见的结果是:模型只看到最后一句话,前面几轮全丢,回复牛头不对马嘴。

正确做法是把 messages 按对话顺序配对:

def messages_to_history(messages: List[Message]): history = [] pending_user = None for m in messages: if m.role == "user": if pending_user is not None: # 上一轮用户话没被回答,补一个空回答,避免错位 history.append((pending_user, "")) pending_user = m.content elif m.role == "assistant" and pending_user is not None: history.append((pending_user, m.content)) pending_user = None return history, pending_user or ""

如果你用的是带apply_chat_template的模型版本,走另一条路更稳:

inputs = tokenizer.apply_chat_template( [{"role": m.role, "content": m.content} for m in messages], add_generation_prompt=True, return_tensors="pt", ).to(model.device) outputs = model.generate( inputs, max_new_tokens=req.max_new_tokens, temperature=req.temperature, top_p=req.top_p, do_sample=req.temperature > 0, repetition_penalty=1.05, ) reply = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)

注意outputs[0][inputs.shape[1]:]这个切片,不加的话你会把输入的 prompt 原样带回来,前端一看输出开头是自己发的问题,很尴尬。另外do_sample要跟 temperature 联动,temperature 为 0 时应该关采样走贪心,否则某些版本会报错或者输出退化。

历史该由谁维护有一个架构上的选择。我倾向让服务端无状态:客户端每次把完整 messages 传上来,服务端只负责转换成 history 跑一次推理。这样服务可以水平扩展(虽然模型实例不能扩,但路由层可以),也不会因为服务重启把用户的上下文弄丢。代价是每次请求都重复计算历史部分的 KV,长对话下延迟会明显上涨。真要做性能优化,再引入 prefix caching 或者会话级 KV 缓存,那是后话,别一开始就把架构做复杂。

3.3 流式输出:SSE 怎么接才不踩坑

非流式接口在生成 500 字时,用户盯着转圈要等十几秒,体验很差。流式输出是刚需,而其中最省事的方案是 SSE(Server-Sent Events),本质就是一个长连接的text/event-stream响应,一行行往下推。

from fastapi.responses import StreamingResponse import json def sse_pack(data: dict) -> str: return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" @app.post("/v1/chat/stream") def chat_stream(req: ChatRequest): history, query = messages_to_history(req.messages) def gen(): yield sse_pack({"event": "start"}) for chunk, _ in model.stream_chat( tokenizer, query, history=history, temperature=req.temperature, top_p=req.top_p, max_length=req.max_new_tokens, ): yield sse_pack({"event": "delta", "text": chunk}) yield sse_pack({"event": "done"}) yield "data: [DONE]\n\n" return StreamingResponse(gen(), media_type="text/event-stream")

stream_chat返回的是(累积文本, 本次新增)的迭代器,它每次给的是到目前为止的完整文本,不是增量片段。所以前端要展示增量,得自己算差集,或者你在服务端就把它切成增量再推。我一般选后者,服务端算 diff 更省前端心智:

prev = "" for full_text, _ in model.stream_chat(...): delta = full_text[len(prev):] prev = full_text if delta: yield sse_pack({"event": "delta", "text": delta})

流式有三个具体的坑。第一是中间层缓冲,某些网关会攒够一定字节才转发,导致你这边一秒一个字对方那边十秒一坨。解决思路是在响应头里加X-Accel-Buffering: no,以及确认media_type里带了charset=utf-8。第二是客户端超时,requests 默认没有读超时,但很多 HTTP 客户端有默认 30 秒,长回答会被砍。第三是断连处理,用户中途关页面,服务端还在傻算,必须有机制感知到客户端断开并终止生成,否则显存一直被占着。

提示:SSE 是单向的,客户端没法中途发消息给服务端。想要"停止生成"按钮,得另开一个POST /v1/chat/cancel接口,用请求 id 去标记取消状态,生成循环里检查这个标记。

3.4 加一层 OpenAI 兼容接口的真实价值

到这一步你的服务能用了,但调用方还是要按你的自定义格式写代码。如果叠一层/v1/chat/completions,情况就不一样了:现成的 SDK、评测脚本、Agent 框架、甚至一些低代码平台,改个base_url就能指过来。

核心是把字段对齐。请求侧主要映射这几项:messages直接透传,temperature、top_p、max_tokens(注意 OpenAI 叫max_tokens,很多本地实现叫max_new_tokens,别混)、stream。响应侧结构要贴近:

{ "id": "chatcmpl-abc123", "object": "chat.completion", "created": 1710000000, "model": "local-chatglm", "choices": [ { "index": 0, "message": {"role": "assistant", "content": "..."}, "finish_reason": "stop" } ], "usage": {"prompt_tokens": 32, "completion_tokens": 128, "total_tokens": 160} }

流式的话,每个 chunk 长这样,最后一个是data: [DONE]:

{"id":"chatcmpl-abc123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}

usage字段很多人懒得填,但只要有计费或者统计需求就绕不开。token 数不需要精确到跟模型内部完全一致,用 tokenizer 数一遍即可:

prompt_tokens = len(tokenizer.encode(prompt_text)) completion_tokens = len(tokenizer.encode(reply_text))

有一点要提前跟调用方说清楚:本地模型的finish_reason只有stop(遇到结束符)和length(被 max_tokens 截断)两种,而 OpenAI 还有tool_calls、content_filter等。如果你的兼容层不返回这些,依赖工具调用的框架会直接跑不通,别以为改个 URL 就万事大吉。

4. 客户端怎么调:几种姿势和对应注意点

4.1 requests 裸调:最笨但最可控

联调阶段我最喜欢用这个,因为能看清每一个字节。

import requests resp = requests.post( "http://127.0.0.1:8000/v1/chat", json={ "messages": [{"role": "user", "content": "用三句话说清什么是向量检索"}], "temperature": 0.7, "max_new_tokens": 256, }, timeout=(5, 120), # 连接 5s,读 120s ) resp.raise_for_status() print(resp.json()["content"])

timeout一定要写成元组,只写一个数字会被当成同时作用于连接和读取,长文本生成必然超时。这个细节坑过不少人,症状是"本地测都好好的,接了业务就报 ReadTimeout"。

流式版本:

with requests.post(url, json=payload, stream=True, timeout=(5, 300)) as r: for line in r.iter_lines(decode_unicode=True): if not line or not line.startswith("data: "): continue data = line[6:] if data == "[DONE]": break print(data)

iter_lines而不是iter_content,因为 SSE 是按行分帧的,按字节块读会拿到半行,解析直接崩。

4.2 用 openai SDK 指向本地

如果你的兼容层做完了,客户端代码几乎不用改:

from openai import OpenAI client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="not-needed") stream = client.chat.completions.create( model="local-chatglm", messages=[{"role": "user", "content": "帮我写一个正则,匹配 11 位手机号"}], stream=True, temperature=0.3, ) for chunk in stream: delta = chunk.choices[0].delta.content if delta: print(delta, end="", flush=True)

api_key随便填个非空字符串即可,SDK 会校验非空但本地服务通常不验。如果你在服务端加了简易鉴权,那就填真实 token。model字段本地服务可以不校验,但建议保留透传,将来多模型路由时能直接用上。

4.3 前端接入:跨域和首字延迟

前端直连本地服务,第一个拦路虎是 CORS。加中间件就好:

from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://内网域名"], allow_methods=["*"], allow_headers=["*"], )

别图省事写allow_origins=["*"]还带allow_credentials=True,浏览器会直接拒绝这个组合。

第二个是首字延迟。用户点击发送到看见第一个字,这段时间包括网络往返、prompt 编码、prefill 计算。6B 模型在消费级卡上,1K prompt 的 prefill 大概几百毫秒到一秒多,加上排队,体感可能两三秒。改善手段有两个:一是先把 loading 状态立刻显示出来,让用户知道系统收到了;二是服务端在开始生成前先推一个空的心跳事件,防止中间的网关认为连接空闲而断开。

5. 并发、吞吐和稳定性:这部分决定它能不能真上线

5.1 单卡并发为什么会排队,以及排队该怎么办

一张卡上的模型,同一时刻只能算一个 batch。你开 10 个线程同时打请求,它们不是并行推理,而是在抢同一把锁,实际是串行执行,总耗时约等于 10 次单次耗时之和。这不是代码写得不好,是物理限制。

所以第一个认知要纠正:加线程不会提升吞吐,只会提升排队体验。真正能提吞吐的手段只有三个:批处理(把多个请求拼成一个 batch 一次前向)、更快的推理引擎(更好的 kernel 和调度)、更大的卡或更多卡。批处理是最容易拿到的收益,vLLM 这类引擎之所以快,很大一部分来自 continuous batching。

如果暂时不换引擎,那至少要把排队做对:请求进来先入队,由一个工作线程消费队列,队列长度设上限,超了直接返回 429 而不是无限堆积。否则高峰期所有请求都在等,最后一个超时全烂掉,用户感知就是"服务挂了"。

import asyncio semaphore = asyncio.Semaphore(2) # 最多同时 2 个在算 @app.post("/v1/chat") async def chat(req: ChatRequest): async with semaphore: return await asyncio.to_thread(run_inference, req)

用asyncio.to_thread把同步推理扔到线程池,避免阻塞事件循环——这一步不做的话,一个长请求会把整个服务卡住,连健康检查都探不通。信号量控制并发数,数值取多少要看显存余量,宁可小一点,让排队在客户端可见,也别让 OOM 把整服务带走。

5.2 参数怎么配:一份能直接用的默认值

参数没有绝对最优,但有明显不合理的组合。给一份我常用的起点:

参数默认取值范围调整逻辑
temperature0.70~1.0抽取类任务 0.1~0.3,创意类 0.8~1.0
top_p0.80.1~1.0与 temperature 二选一调,别同时拉满
max_new_tokens51264~4096按业务最大回复长度定,留给显存的余量要够
repetition_penalty1.051.0~1.2出现复读时小步上调,超过 1.2 容易语句破碎
上下文上限8K视模型而定超了要截断历史,从最早的轮次开始丢

关于temperature和top_p同时调的问题多说一句:这俩都在做"从概率分布里怎么选词",一起拧容易把分布改得面目全非。我的习惯是固定top_p=0.8,只动temperature,这样行为可预期。

max_length和max_new_tokens这两个参数在不同版本的接口里含义不一样,有的版本max_length指总长度(输入+输出),有的指新生成长度。上线前一定要用固定输入实测一次,看返回被截在多少字,别照抄别人的配置。

5.3 超时、限流和降级:别让一个慢请求拖垮全部

一套能扛住真实流量的服务,至少要有三层保护。

第一层是单请求超时。推理线程里加个时间检查,超过 N 秒强制停。但注意model.generate是阻塞的,没法从外面打断,可行的做法是用StoppingCriteria:

from transformers import StoppingCriteria, StoppingCriteriaList import time class TimeLimit(StoppingCriteria): def __init__(self, seconds): self.deadline = time.time() + seconds def __call__(self, input_ids, scores, **kwargs): return time.time() > self.deadline stopping = StoppingCriteriaList([TimeLimit(60)])

第二层是入口限流。按 IP 或按调用方做令牌桶,超出直接 429 带Retry-After。这一层不一定要写进服务里,放在前面的网关更合适。

第三层是降级。当队列积压超过阈值,把max_new_tokens临时压到 128,或者直接返回"当前繁忙,请稍后再试"。听起来粗暴,但比让所有请求都超时好得多。

5.4 加日志和指标,不然后面全是玄学

我强烈建议从第一版就把这几项记下来:请求 id、输入 token 数、输出 token 数、排队耗时、首字耗时、总耗时、是否被截断。有了这些,用户说"今天特别慢"你能立刻定位是排队长了还是单次推理慢了。

import logging, time logger = logging.getLogger("llm-api") t_start = time.time() first_token_at = None ... logger.info({ "req_id": req_id, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "queue_ms": int((t_infer - t_start) * 1000), "ttft_ms": int((first_token_at - t_infer) * 1000) if first_token_at else None, "total_ms": int((time.time() - t_start) * 1000), "finish_reason": finish_reason, })

ttft(time to first token)这个指标特别有用,因为它跟用户感知最贴近。总耗时 8 秒但 ttft 只有 300 毫秒,用户是能忍的;ttft 就 5 秒,他会以为卡死了。

6. 踩过的坑和排查速查表

6.1 显存相关:OOM 的七种面孔

CUDA out of memory是最常见的报错,但原因差别很大,得对症。

现象可能原因处理
启动就 OOM权重精度不对,按 fp32 加载了显式指定torch_dtype
启动就 OOM端口上还有旧进程没退nvidia-smi查 PID,kill 掉
跑一会儿 OOMKV Cache 随请求累积降max_new_tokens、限制并发
长文本时 OOM上下文超预期加历史截断逻辑
多 worker 时 OOM每个 worker 加载一份模型workers 设为 1
报错但不是 OOM显存碎片重启服务,或设PYTORCH_CUDA_ALLOC_CONF
偶发 OOM峰值并发超过预估上信号量限流

有个特别隐蔽的情况:服务刚起、显存占用很漂亮,跑了半小时突然 OOM。这八成是历史记录没做长度控制,某个用户的对话越聊越长,某一轮直接把上下文顶到上限。解法是在拼接 history 时按 token 数反向截断,保留最近 N 轮:

def trim_history(history, tokenizer, max_tokens=6000): total = 0 kept = [] for q, a in reversed(history): cost = len(tokenizer.encode(q)) + len(tokenizer.encode(a)) if total + cost > max_tokens: break total += cost kept.append((q, a)) return list(reversed(kept))

6.2 输出质量异常:复读、截断、乱码、不听话

一直在重复同一句话,通常是repetition_penalty没开或者 temperature 太低。小步往上调 penalty,1.05 → 1.1,别一次拉到 1.3,那会让句子变得支离破碎。也检查一下是不是历史里塞了重复内容,模型被带偏了。

回答到一半就断,先看finish_reason。如果是length,就是max_new_tokens不够,往上调,同时留意显存。如果返回里根本没有结束标记,那可能是流式连接被中间层掐了,去看 5 秒 keep-alive 那个参数。

输出全是乱码或者特殊符号,八成是 tokenizer 和模型不匹配,或者权重没加载成功(随机初始化)。回退到最小脚本测一次,如果最小脚本也乱,那就是模型文件本身有问题,检查是不是下载不完整、分片缺了。

模型完全不按格式回答,比如你要求输出 JSON 它偏要加一段解释。这类指令遵循能力跟模型规模和微调程度强相关,本地小模型本来就弱。可行的补救是 few-shot,在 messages 里塞一两个正确示例,命中率会明显提高。我实测下来,给两个示例比写一段"你必须输出 JSON"的强指令更有效。

6.3 服务层面:连不上、502、乱序

本地 curl 通,容器里不通,先确认服务监听的是0.0.0.0而不是127.0.0.1。再确认端口映射对上了,curl http://127.0.0.1:8000/docs能打开说明服务活着。

502 或连接被重置,常见于响应时间超过网关超时。查一下反向代理的proxy_read_timeout,默认值通常 60 秒,长回答会被砍断。

流式输出顺序乱了或者内容重复,检查你的 SSE 分帧是否符合规范——每帧必须以\n\n结尾,少一个换行解析器就会把两帧粘一起。另外别在生成器里用print调试,混进响应体会直接污染数据流。

中文变成\u4f60\u597d这种转义,json.dumps少了ensure_ascii=False,加上就好。

注意:调试阶段建议把每个请求的原始请求体和前 200 字响应落盘,出问题时能快速复现。但涉及用户内容要按合规要求处理,别把这些日志随手丢在公开可读的目录里。

6.4 一份可以直接照抄的启动检查清单

上线前我会照这个清单过一遍,能省掉大部分低级故障:

  1. 显存峰值实测过,用的是真实最大上下文和最大并发,不是随便发两句话。
  2. workers是 1,确认没有第二个进程偷偷占着卡。
  3. max_new_tokens和上下文上限有硬性约束,不是敞开的。
  4. 历史截断逻辑存在,且按 token 数算,不按轮数算。
  5. 入口有并发控制,超限返回 429 而不是排队到死。
  6. 流式的 keep-alive 和代理超时都调过,长回答能完整吐完。
  7. 日志里有 ttft、总耗时、finish_reason 三个字段。
  8. 客户端断开时服务端能感知并终止生成,显存能释放。
  9. /health探针只检查进程存活,不触发模型推理,否则会被探针打满。
  10. 兼容层的字段映射测过,尤其max_tokens和max_new_tokens。

我自己在这个项目上最实在的一条体会是:别急着上复杂的推理引擎,先把"一个进程、一把锁、一个队列"这套最朴素的模型跑稳。很多团队一上来就折腾高性能方案,结果连"用户点了停止按钮显存没释放"这种小问题都没解决,性能再高也是白搭。等你把队列长度、ttft、显存峰值这几个数摸清楚了,再决定要不要换引擎、要不要加卡,判断会踏实得多。另外一个小技巧,本地调试时把temperature设成 0 走贪心,输出稳定可复现,定位问题时不会因为"这次刚好生成了别的词"而怀疑人生。