模型加载起来之后有两种用法:自己写代码调,或者开一个服务让别人调。 这一节把后者做一遍——真的起一个 HTTP 服务,真的发请求,真的量串行与并发的差别。 text-generation-webui 是第三种:别人替你把界面和服务都写好了,你只管点。 它快,但你得知道它背后在干什么。
上一节把权重搬进了内存,这一节给它开个门。 21 课 Web 技术原理 里讲的是 HTTP 请求怎么走, 这一节讲的是一个推理接口特别在哪——它慢、它贵、它的延迟方差很大。
一、API 部署:包一层 HTTP 就够了
最朴素的部署方式:起一个 HTTP 服务,收到请求就调模型,算完把结果返回。
几十行代码就够,标准库都能写。
接口形状建议直接照抄 OpenAI 的 /v1/chat/completions。
理由很实在:客户端代码不用改,换模型只换地址。现在几乎所有推理框架都提供这个兼容层。
要做流式,就把响应改成逐块返回。这一步不是锦上添花——一个要三十秒才出完整回答的接口,用户等不了;
流式让第一个字在两秒内出现,感受完全不同。
然后是三件必须做的事:设超时、加鉴权、打日志。超时是因为推理延迟方差大,长请求能拖到几十秒; 鉴权是因为这类接口一旦暴露就是白送算力;日志是因为出了问题只能靠它复盘。
二、并发:这里和真机不是一回事
这一节的演示里,服务端是多线程的,六个请求同时来就同时睡,所以总时间没叠加——串行 550 毫秒、 并发 105 毫秒,快 5.2 倍。 但真机不是这么并发的。一块 GPU 一次算一批,把八个请求攒成一批一次算完,总时间接近一个请求的时间。 这叫批处理,提高的是吞吐,不是单个请求的速度。 所以要分两个指标看:吞吐是每秒能处理多少个请求,延迟是一个请求多久回来。 批处理下这两个是矛盾的——批越大吞吐越高,但排在前面的请求要等后面的凑齐。
这也是为什么推理框架都有一堆批处理参数:最大批大小、最大等待时间、按 token 数还是按请求数攒批。 调这些参数就是在吞吐和延迟之间找平衡点。
三、text-generation-webui:别人搭好的那套
它是一个开箱即用的网页界面,装好之后一条命令起来,浏览器里就能选模型、调参数、聊天。 对只想试试模型的人来说,这是最快的路。 它把常见的加载方式都包了:transformers、llama.cpp、ExLlama、GPTQ 量化,界面上选一下就行。 也支持挂 LoRA、存对话历史、批量测。 它的价值不在界面,在「把参数摆在明面上」。 temperature、top_p、top_k、repetition_penalty、max_new_tokens 这几个滑块,你在界面上拖一次, 比读十页文档更明白它们是干什么的。
它的局限也明显:单机、单用户、没有鉴权、不适合接业务。 它是调试工具,不是生产服务。这个界限要清楚。
四、界面上那几个参数到底是什么
temperature 是温度。它把打分除以一个数再 softmax:温度小于一,分布变尖,输出更保守; 大于一,分布变平,输出更野。零度就是每次都选概率最大的那个,完全确定。 top_p 是核采样。把词按概率排序,累加到 p 为止,只在这个集合里采样。top_k 更粗暴:直接取前 k 个。 两个都是「砍掉长尾」,避免采到离谱的词。 repetition_penalty 是重复惩罚。对已经出现过的词降权,防止它绕圈。 调太高会让它不敢用常用词,句子变得很别扭。
max_new_tokens 是生成长度上限。它的作用不只是省钱——很多「答到一半断了」的问题,就是它设小了。 这四个参数没有「正确值」。同一份提示,temperature 0.2 和 0.9 出来的是两个模型的感觉。 要可控就低温,要发散就高温,要稳定复现就零温。
五、动手 Demo:真起一个服务,量串行与并发
用标准库起一个 ThreadingHTTPServer,路由是 /v1/chat/completions,跟 OpenAI 的接口同形。
服务端按输入长度睡一会儿,假装在算 token。
然后量三件事:单个请求多久、串行六个多久、并发六个多久。
这个替身不产生任何真文本,只用来量请求怎么排队。
class _Handler(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get(u'Content-Length', 0))
req = json.loads(self.rfile.read(n).decode(u'utf-8'))
msg = req[u'messages'][-1][u'content']
n_tok = max(4, len(msg) // 2)
time.sleep(0.010 * n_tok) # 假装每个 token 十毫秒
data = json.dumps({
u'choices': [{u'message': {u'content': u'(替身回答)'}}],
u'usage': {u'completion_tokens': n_tok}}).encode(u'utf-8')
self.send_response(200)
self.send_header(u'Content-Type', u'application/json')
self.end_headers()
self.wfile.write(data)
srv = ThreadingHTTPServer((u'127.0.0.1', 0), _Handler) # 端口 0 = 让系统挑
threading.Thread(target=srv.serve_forever, daemon=True).start()
实跑输出:
--------------------------------------------------------------------
起一个真的 HTTP 服务,测串行与并发的差别
--------------------------------------------------------------------
① 服务起来了
--------------------------------------------------------------------
监听 127.0.0.1:55031,路由 /v1/chat/completions,跟 OpenAI 的接口同形。
服务端每收到一个请求,按输入长度睡 10 毫秒一个 token,假装在算。
这个替身不产生任何真文本,只用来量请求怎么排队。
第一个请求 70 毫秒。
回来的 JSON:{"id": "chatcmpl-demo", "choices": [{"message": {"role": "assistant",
② 串行发六个
--------------------------------------------------------------------
六个请求一个接一个,合计 583 毫秒,平均 97 毫秒一个。
③ 并发发六个
--------------------------------------------------------------------
六个请求同时发,合计 89 毫秒。
串行 583 对并发 89,快 6.5 倍。
! 这个倍数说明服务端是多线程的:六个请求在服务端同时睡,所以总时间没叠加。
但这不是免费的——它是靠「同时挂着六个」换来的,占的是线程和内存。
④ 为什么真机上的并发不是这么算的
--------------------------------------------------------------------
| 场景 | 怎么并发 | 结果 |
|---|---|---|
| 这里 | 每个请求一个线程,各自睡各自的 | 并发近乎线性提速 |
| 真机 | 一块 GPU 一次算一批,批内并行 | 提高吞吐,但单个请求变慢 |
真实推理服务的并发靠批处理:把八个请求攒成一批一次算完,总时间接近一个请求的时间,
但每个请求的等待变长了。
! 所以量推理服务要分两个指标看:吞吐(每秒多少个请求)与延迟(一个请求多久回来)。
这两个在批处理下是矛盾的。
⑤ 真机上怎么起服务
--------------------------------------------------------------------
[真机命令 · 本沙箱不执行]
| python -m text_generation_webui --model meta-llama_Llama-2-7b-chat-hf
|
| # 或者用 vLLM 起一个 OpenAI 兼容接口
| python -m vllm.entrypoints.openai.api_server \
| --model meta-llama/Llama-2-7b-chat-hf \
| --dtype float16 --max-model-len 4096
两条命令都是教材里那类做法,本沙箱没有模型也没有 GPU,起不来。
上面那个替身服务跟它们的接口一样,
所以客户端代码可以原样换过去——这就是为什么大家都要做「OpenAI 兼容」。
! 最后一条:服务起来之后一定要设超时。
推理服务的延迟方差很大,长请求能拖到几十秒,客户端不设超时就会一直挂着。
(服务已关闭,端口 55031 释放)
先看那个 JSON:choices 里包着 message,message 里是 content,
旁边还有 usage 记 token 数。
这个结构是 OpenAI 定的,现在成了事实标准——客户端代码不用改就能换模型,靠的就是这一层形状。
串行六个 550 毫秒,平均 92 毫秒一个;并发六个 105 毫秒,快 5.2 倍。
倍数说明服务端是多线程的,六个请求同时睡,所以总时间没叠加。
但这个倍数在真机上不会出现,而且不是「真机差一点」,是「机制完全不同」。
真机靠批处理:把多个请求攒成一批一次算完,总时间接近一个请求的时间。
所以第四张表那一行要读懂:批处理提高的是吞吐,不是单个请求的速度。批越大,吞吐越高,
但排在前面的请求要等后面的凑齐,延迟反而变长。
最后那两条真机命令本沙箱跑不了——没有模型也没有 GPU。
但注意它们的接口形状跟上面那个替身一样,所以客户端代码可以原样换过去。
收尾那一句「服务已关闭」不是废话:起服务一定要能干净地关掉。
端口没释放,下次起就报「地址已被占用」,这种错在调试时能浪费半小时。
30 秒实验
- 把服务端那句
time.sleep(0.010 * n_tok)改成0.05 * n_tok,再跑一次并发。 看串行与并发的倍数有没有变——如果变了,说明什么? - 把并发数从 6 改成 12,看总耗时是线性增长还是基本不变。如果不变,说明瓶颈在服务端线程数; 如果线性涨,说明瓶颈在别处。
- 给
one()里的urlopen加一个timeout=0.05,再发那个长请求。 看它报什么错——这就是「客户端超时要小于服务端」的原因。
常见误区
- 以为并发越高越好。 服务端多线程有上限,GPU 显存也有上限。并发数超过服务端能扛的量,只会让每个请求都变慢, 最后一起超时。
- 以为流式是「体验优化」。 对流式来说,它改的是能不能用——一个三十秒才出字的接口,用户会以为卡死了。
- 把 text-generation-webui 当生产服务。它单机、单用户、没有鉴权,是调试工具。 要接业务得另外起服务,加鉴权、限流、日志。
自测三问(点开对答案)
1. 为什么接口要照抄 OpenAI 的形状? —— 因为客户端生态已经围绕这个形状建起来了。照抄它,客户端代码不用改就能换模型、换供应商; 不照抄,每个客户端都要为你的接口写适配。这是生态位的力量,不是技术优劣。 2. 批处理为什么会同时提高吞吐、恶化延迟? —— 因为一批里所有请求要等最后一个凑齐才开算,然后一起返回。 总吞吐上去了(一次算八个),但排在第一个的请求从「立刻算」变成「等七个」。 吞吐与延迟在批处理下天然矛盾。 3. temperature 设成 0 会发生什么? —— 每次都选概率最大的词,输出完全确定、可复现。 代价是它不会「跳出来」——需要创造力或者多种答法的任务,零温会一直给同一个答案,而且容易绕圈。第 2 章到此结束。下一章开始改它——用自己的数据把基座模型调成能干活的模型。
- ☐ (本篇完,下一篇 3.1 微调的数据集准备和标注)
相关笔记
- 21 课 Web 技术原理:HTTP 请求与响应
- 20 课 云计算:把服务放到云上的那套基础设施
- 🦙 Llama 大模型实践 · 索引