🦙 第2章 部署 Llama 2 大模型
📄 一句话概括

模型加载起来之后有两种用法:自己写代码调,或者开一个服务让别人调。 这一节把后者做一遍——真的起一个 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 秒实验

  1. 把服务端那句 time.sleep(0.010 * n_tok) 改成 0.05 * n_tok,再跑一次并发。 看串行与并发的倍数有没有变——如果变了,说明什么?
  2. 把并发数从 6 改成 12,看总耗时是线性增长还是基本不变。如果不变,说明瓶颈在服务端线程数; 如果线性涨,说明瓶颈在别处。
  3. 给 one() 里的 urlopen 加一个 timeout=0.05,再发那个长请求。 看它报什么错——这就是「客户端超时要小于服务端」的原因。

常见误区

  1. 以为并发越高越好。 服务端多线程有上限,GPU 显存也有上限。并发数超过服务端能扛的量,只会让每个请求都变慢, 最后一起超时。
  2. 以为流式是「体验优化」。 对流式来说,它改的是能不能用——一个三十秒才出字的接口,用户会以为卡死了。
  3. 把 text-generation-webui 当生产服务。它单机、单用户、没有鉴权,是调试工具。 要接业务得另外起服务,加鉴权、限流、日志。
自测三问(点开对答案) 1. 为什么接口要照抄 OpenAI 的形状? —— 因为客户端生态已经围绕这个形状建起来了。照抄它,客户端代码不用改就能换模型、换供应商; 不照抄,每个客户端都要为你的接口写适配。这是生态位的力量,不是技术优劣。 2. 批处理为什么会同时提高吞吐、恶化延迟? —— 因为一批里所有请求要等最后一个凑齐才开算,然后一起返回。 总吞吐上去了(一次算八个),但排在第一个的请求从「立刻算」变成「等七个」。 吞吐与延迟在批处理下天然矛盾。 3. temperature 设成 0 会发生什么? —— 每次都选概率最大的词,输出完全确定、可复现。 代价是它不会「跳出来」——需要创造力或者多种答法的任务,零温会一直给同一个答案,而且容易绕圈。

第 2 章到此结束。下一章开始改它——用自己的数据把基座模型调成能干活的模型。

相关笔记