从脚本到服务:同一段代码,10 个人同时用会怎样
实测中位等待 4.6s → 0.8s;以及服务化的三张入场券
到这里你手上有一堆能跑的脚本:会重试、会缓存、会检索、会评测、会降级。但它们全都是"你在终端里敲 python3 xxx.py"。
产品不是这样交付的。这一章把它变成别人能用的东西,而第一步就有个坑:
同一段业务代码,只是包成 HTTP 服务的方式不同,10 个用户同时提问——
- 单线程服务:中位等待 4.6 秒,最慢的那个等了 8 秒;
- 多线程服务:中位等待 0.8 秒,最慢 1.1 秒。
业务代码一个字没改。第 10 个用户等的那 8 秒,全是排队。
你在家做饭,做一道等一道,没问题——只有你一个人吃。
开成餐馆之后,10 桌客人同时点菜,你还是一道一道做:第一桌 5 分钟上菜,第十桌等了 50 分钟。菜谱没错,厨房的组织方式错了。
实测:一行服务器类的差别
【单线程 HTTPServer】
10 个请求全部完成耗时: 8.0s
单请求等待:最快 0.9s | 中位 4.6s | 最慢 8.0s
【多线程 ThreadingMixIn】
10 个请求全部完成耗时: 1.1s
单请求等待:最快 0.8s | 中位 0.8s | 最慢 1.1s
差别只有这一行:
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
daemon_threads = True
为什么线程有效:调用大模型的这几秒里,你的进程什么也没干,只是在等网络(ap1.3 讲并发时是同一个道理)。单线程服务器在等的时候不接新请求,于是所有人排队。
要看的指标是"中位等待",不是"总耗时"。总耗时是给你自己看的,中位等待是用户真实的体感——4.6 秒和 0.8 秒是"卡"和"快"的区别。
生产里当然用 FastAPI / Flask + Gunicorn / Uvicorn,不会手写
http.server。本节用标准库是为了让你看清里面到底发生了什么——换成任何框架,你要调的还是同一件事:worker 数 / 线程数 / 是不是 async。
服务化的三张入场券
脚本崩了只有你看见,服务崩了是所有用户看见。三件事必须做:
① 校验在最前面
if not q: return self._json(400, {"error": "question 不能为空"})
if len(q) > 500: return self._json(400, {"error": "question 过长"})
实测输出:
空 question → 400 {"error": "question 不能为空"}
超长 question → 400 {"error": "question 过长"}
坏 JSON → 400 {"error": "bad_json"}
长度上限那一条是在保护你的钱包:没有它,一个用户粘贴 10 万字进来,你就为他付了这笔钱(ap6.4 的配额是按次数,这一条是按体积)。
② 错误码要分清是谁的错
| 情况 | 返回 | 为什么 |
|---|---|---|
| 参数不对、JSON 坏了 | 4xx | 是调用方的错,别重试 |
| 超配额、限流 | 429 | 让客户端知道该等多久(ap6.4) |
| 上游模型挂了 | 502 | 不是 500——是依赖的服务挂了,不是你的代码崩了 |
| 你自己的代码抛异常 | 500 | 这个应该告警(ap7.4) |
这个区分不是洁癖:客户端要靠状态码决定"重试还是放弃"(ap1.1),监控要靠它区分"用户输错了"和"我的服务坏了"。全都返 500 的服务,监控面板永远在骗你。
③ 永远不要把异常直接吐给用户
except Exception as e:
return self._json(502, {"error": "upstream_failed"}) # 不要 str(e)
原始异常里可能带着你的 URL、参数,甚至 key 的片段(ap1.5)。给用户一个稳定的错误码,把细节记进日志。
接口该长什么样
POST /ask
{ "question": "...", "session_id": "..." } # 请求
{ "answer": "...", # 响应
"tokens": 123,
"latency_ms": 812,
"request_id": "req_a1b2c3", # ← 排查问题的钥匙
"degraded": false } # ← 这次是不是降级答的(ap6.1)
三个容易被漏掉的字段:
request_id:每个请求一个,同时写进响应和日志。用户来投诉"刚才那次答得不对",没有它你根本找不到是哪次;degraded:让前端知道这次走的是降级链路,可以给个"当前服务繁忙,结果可能不完整"的提示;latency_ms/tokens:前端不一定用,但它们让你的接口自带可观测性(ap7.4)。
🔧 动手做:把你的脚本变成服务(20 分钟)
- 跑本节代码,亲眼看中位等待 4.6s → 0.8s。
- 选一段你最想交付的脚本(建议是 AP4 的 RAG 问答或 AP3 的抽取),包成一个
POST /ask。 - 加三张入场券:参数校验(含长度上限)、错误码分级(4xx/429/502/500)、异常不外泄。
- 加
request_id:生成一个短 id,写进响应 + 写进 ap1.6 的成本流水——这两处能对上,排查就不再靠猜。 - 压一下:用本节的并发脚本打 10 个并发,看你的中位等待是多少。如果超过 3 秒,先去看是不是单 worker。
- 把 ap6.4 的配额和 ap6.1 的降级接进这个入口——服务化之后,它们才有统一的落点。
✅ 做到这里你应该有:一个能并发处理的 HTTP 接口 + 三级错误码 + request_id 贯通 + 一次并发压测数据。
为什么:这一节是整门课的转折点。前面六章你在打磨"一次调用怎么又好又便宜又安全";从这一节开始,你面对的是"很多人同时用"。这两件事的难点完全不同——而绝大多数 AI Demo 死在这个转折点上:在作者电脑上完美,一发出去就卡成一团。
多线程解决的是"等网络时能不能接新请求",没有解决"上游能不能扛住"。并发开太大,你会撞上三堵墙:
① 上游限流:模型服务商的 QPS 上限(ap1.3 实测过,并发 20 已接近收益上限); ② 内存:每个线程一份上下文,长文本场景下涨得很快; ③ 你自己的下游:数据库连接池、向量库、日志写入。
实践做法:进程内并发设一个明确的上限(比如 20-50),超出的排队而不是直接开线程,并且给队列一个最大等待时间——等 30 秒还没轮到,不如立刻告诉用户"现在人多,请稍后",这比让他盯着转圈强(ap6.1 的降级思路在这里同样适用)。
这是我的脚本:【贴代码】。请帮我把它改成一个可上线的 HTTP 服务(用【FastAPI/Flask】),要求:1) 请求/响应的 JSON 结构设计,含 request_id、tokens、latency_ms、degraded 字段;2) 参数校验放在最前面(含长度上限);3) 错误码分级:参数错 4xx、超配额 429、上游失败 502、内部异常 500,异常细节不外泄只记日志;4) 并发配置建议(worker/线程/async 怎么选,上限设多少);5) 一段并发压测脚本,输出中位和 P95 等待时间。
服务化三件事:并发方式决定用户体感(实测中位等待 4.6s → 0.8s)、三张入场券(校验在最前、错误码分清是谁的错、异常不外泄)、接口自带 request_id / degraded / tokens。
下一节讲多轮对话真正的坑:不是技术难,是钱——每多说一轮,你都要把之前所有内容重新付一遍费。
🔎 来源与核验· 2 条,点开核对
