1. 项目背景业务场景某消息推送平台需要向 10 万个用户发送个性化短信——每个用户的手机号、姓名、套餐内容不同但 API 端点、认证方式、HTTP 头完全一致。最初方案是循环调用requests.post(url, jsonuser_data, headersauth_headers)——10 万次循环。运行后发现每次requests.post()都在内部重新做一次完整的请求编码——URL 拼接、参数编码、headers 合并、body 序列化。其中 95% 的工作是完全重复的同一 URL、同一认证头、同一请求体结构只有user_data中的几个字段在变化。更糟糕的是在日志排查时发现某些请求的 headers 和预期不同——可能是 requests 内部参数解析逻辑在特定边界条件下产生了非预期的行为。但由于请求是黑盒发出去的无法在发送前审计最终的 HTTP 报文内容。痛点问题一请求构建与发送耦合。requests.get(url, params..., headers...)把构建请求和发送请求两个步骤合为一体。在批量场景下每次调用都在重复构建相同的部分浪费 CPU 和内存。如果能将请求预编译成不可变的 PreparedRequest 对象后续发送时只需替换变化的参数即可。问题二PreparedRequest 的可见性优势。PreparedRequest 是发送前的最终状态——所有隐式头Host、User-Agent、Accept-Encoding 等都已经添加、参数已经编码、body 已经序列化。通过审计 PreparedRequest可以 100% 确定到底发出了什么这对于安全审计和问题排查至关重要。问题三请求签名场景的黄金窗口。许多 API 需要对请求做签名HMAC-SHA256、AWS Signature V4。签名的输入是发送前的最终请求。如果在构建请求后、发送前能拦截 PreparedRequest 并插入签名逻辑就能完美解决签名问题。而Session.send(prepared)正是这个黄金窗口。PreparedRequest 完整链路requests.get(url, params{...}, headers{...}, json{...}) │ ├─ 构建 Request 对象 │ Request(method, url, params, headers, json, ...) │ ├─ 准备 PreparedRequest │ session.prepare_request(Request) │ ├─ prepare_method() # 大写化方法名 │ ├─ prepare_url(url, params) # 拼接 URL 编码参数 │ ├─ prepare_headers(headers) # 合并 Session 默认头 隐式头 │ ├─ prepare_body(data, json, files) # 序列化请求体 │ └─ prepare_cookies(cookies) # 提取匹配的 Cookie │ ├─ 【这里可以插入签名/修改逻辑】 │ └─ 发送 PreparedRequest session.send(prepared)2. 项目设计小胖看着终端里 10 万次循环的慢速输出“大师我们的通知发送服务太慢了——10 万条消息每条都要调requests.post()10 万次循环就是 10 万次请求编码。光构建请求就花了 5 秒”大师查看代码指了指requests.post(url, jsondata)“你每次都在让 requests 从零构建整个请求——URL 编码、headers 合并、body 序列化。但这些步骤中 95% 都是一样的你应该用 PreparedRequest——把请求模板预编译一次后续只替换变化的字段。”小胖挠头“PreparedRequest这是什么我在文档里好像没见过。”大师“PreparedRequest 是 requests 内部请求构建-发送分离模型的体现。你可以理解为Request 是原料面粉、鸡蛋、牛奶PreparedRequest 是烤好的面包——已经成形、不可改变。你只要把面包放进烤箱session.send()就行。”fromrequestsimportRequest,Session sessionSession()# 1. 构建请求模板只做一次templateRequest(POST,https://api.push.com/send,headers{Authorization:Bearer xxx,Content-Type:application/json},)# 2. 预编译为 PreparedRequest# 注意prepared.body 此时为空还没设数据preparedsession.prepare_request(template)# 3. 批量发送——只改 bodyimportjsonforuserinusers:prepared.bodyjson.dumps(user).encode(utf-8)responsesession.send(prepared,timeout5)小白推了推眼镜追问“prepare_request()都做了什么为什么能节省时间”大师在白板上画出流程图prepare_request()内部执行五个步骤——这就是requests.get()在内部做的事情。当我们批量发送时需要变化的通常只是 body 或 params其他步骤都是重复的。而且更重要的是——PreparedRequest 是可见的。你可以在发送前查看它preparedsession.prepare_request(Request(GET,url,params{q:test}))print(prepared.method)# GETprint(prepared.url)# https://api.example.com/search?qtestprint(dict(prepared.headers))# 所有头包括隐式头print(prepared.body)# NoneGET 请求没有 body小胖“那不就跟 curl 的 --libcurl 一样我可以看到最终发了什么出去”大师“正是而且你甚至可以写一个函数把 PreparedRequest 转成 curl 命令——这在调试和与团队沟通时极其有用。”defto_curl(prepared):将 PreparedRequest 转为 curl 命令cmdfcurl -X{prepared.method}fork,vinprepared.headers.items():cmdf -H {k}:{v}ifprepared.body:cmdf -d {prepared.body.decode()}cmdf {prepared.url}returncmd小白眼睛一亮“那签名的场景呢我们有个 API 需要在 header 里加 HMAC 签名。”大师“PreparedRequest 就是签名的完美窗口——在prepare_request()之后、send()之前PreparedRequest 已经包含了签名的所有输入method url body timestamp。你只需要计算签名后加到 headers 里然后发送。”生活比喻技术映射烤面包一次烤很多片PreparedRequest 预编译半成品披萨撒料不同相同模板 不同 body看快递面单确认信息正确审计 PreparedRequest在包裹上加签名封条prepare - 签名 - send转成快递单号给别人查PreparedRequest 转 curl 命令3. 项目实战环境准备pipinstallrequests分步实现步骤一PreparedRequest 全链路解剖目标手动执行prepare_request的每个步骤理解内部流程。importrequestsfromrequestsimportRequest,Session sessionSession()session.headers.update({X-API-Key:default-key})# Step 1: 构建 Request原料 reqRequest(POST,https://httpbin.org/post,params{source:web},# URL 参数headers{X-Request-ID:req-001},# 自定义头json{user:小胖,action:test},# JSON 请求体)# Step 2: 预编译 preparedsession.prepare_request(req)# Step 3: 审计最终状态 print(*60)print(PreparedRequest 审计)print(*60)print(fMethod:{prepared.method})print(fURL:{prepared.url})print(f\nHeaders ({len(prepared.headers)}):)fork,vinprepared.headers.items():sourceifkin(Host,User-Agent,Accept-Encoding,Accept,Connection):source [隐式]elifkX-API-Key:source [Session默认]elifkX-Request-ID:source [请求显式]elifkContent-Length:source [自动计算]print(f{k}:{v}{source})print(f\nBody:{prepared.body.decode()ifprepared.bodyelse(空)})# Step 4: 转为 curl 命令 defto_curl(p:requests.PreparedRequest)-str:parts[fcurl -X{p.method}]fork,vinp.headers.items():parts.append(f-H {k}:{v})ifp.body:parts.append(f-d {p.body.decode()})parts.append(f{p.url})return \\\n .join(parts)print(f\nCurl 等价命令:)print(to_curl(prepared))运行输出关键部分 PreparedRequest 审计 Method: POST URL: https://httpbin.org/post?sourceweb Headers (8): X-API-Key: default-key [Session默认] X-Request-ID: req-001 [请求显式] Content-Type: application/json [自动设置] Content-Length: 44 [自动计算] User-Agent: python-requests/2.32.4 [隐式] Accept-Encoding: gzip, deflate [隐式] Accept: */* [隐式] Connection: keep-alive [隐式] Host: httpbin.org [隐式] Body: {user: \u5c0f\u80d6, action: test}步骤二批量发送——模板复用目标预编译请求模板循环替换 body 后批量发送对比编码开销。importrequestsimporttimeimportjson# 场景向 100 个用户发送个性化通知 users[{id:i,name:fuser_{i}}foriinrange(100)]urlhttps://httpbin.org/post# ----- 方式 A每次调 requests.post() -----print(方式 A: requests.post() 每次构建)sessionrequests.Session()starttime.perf_counter()foruserinusers:respsession.post(url,jsonuser,timeout10)elapsed_atime.perf_counter()-startprint(f 耗时:{elapsed_a:.3f}s (100 次完整构建发送))# ----- 方式 BPreparedRequest 模板复用 -----print(f\n方式 B: PreparedRequest 模板复用)sessionrequests.Session()# 构建模板只做一次templateRequest(POST,url,headers{Content-Type:application/json})preparedsession.prepare_request(template)starttime.perf_counter()foruserinusers:# 只替换 bodyprepared.bodyjson.dumps(user).encode(utf-8)respsession.send(prepared,timeout10)elapsed_btime.perf_counter()-startprint(f 耗时:{elapsed_b:.3f}s (1 次预编译 100 次发送))print(f\n性能差异: 方式 B 比方式 A{快ifelapsed_belapsed_aelse慢}f{abs(elapsed_a-elapsed_b):.3f}s)print(注意差异主要体现在构建开销上网络时间二者相同)session.close()步骤三PreparedRequest 请求签名目标在 prepare 之后、send 之前插入 HMAC-SHA256 签名。importrequestsimporthmacimporthashlibimporttimeimportjsonclassSignedAPIClient:带请求签名的 API 客户端def__init__(self,api_key:str,api_secret:str):self.api_keyapi_key self.api_secretapi_secret.encode()self.sessionrequests.Session()self.session.headers[X-API-Key]self.api_keydefsigned_request(self,method:str,url:str,**kwargs)-requests.Response:发送签名请求# 1. 构建 Requestreqrequests.Request(method,url,**kwargs)# 2. 预编译preparedself.session.prepare_request(req)# 3. 签名在 prepare 之后、send 之前self._sign(prepared)# 4. 发送returnself.session.send(prepared)def_sign(self,prepared:requests.PreparedRequest):对 PreparedRequest 进行 HMAC-SHA256 签名timestampstr(int(time.time()))bodyprepared.bodyorb# 签名字符串method path timestamp bodysign_str(f{prepared.method}{prepared.path_url}{timestamp}).encode()body signaturehmac.new(self.api_secret,sign_str,hashlib.sha256).hexdigest()# 将签名信息注入 headerprepared.headers[X-Timestamp]timestamp prepared.headers[X-Signature]signaturedefpost(self,url:str,**kwargs)-requests.Response:returnself.signed_request(POST,url,**kwargs)defget(self,url:str,**kwargs)-requests.Response:returnself.signed_request(GET,url,**kwargs)defclose(self):self.session.close()# 使用演示 clientSignedAPIClient(my-api-key,my-super-secret)# 发送签名请求respclient.post(https://httpbin.org/post,json{action:transfer,amount:100},)dataresp.json()print(服务端收到的签名头:)forkin[X-Api-Key,X-Timestamp,X-Signature]:print(f{k}:{data[headers].get(k,N/A)[:40]}...)client.close()可能遇到的坑及解决方法坑1PreparedRequest.body 是可变引用# prepared.body 是一个 bytes 对象的引用# 虽然 PreparedRequest 本身是不可变的但 body 可以被替换prepared.bodynew_body# 这是故意支持的方便模板复用坑2修改 prepared.body 后 Content-Length 不会自动更新# 如果你替换了 body需要手动更新 Content-Length 头prepared.bodyjson.dumps(new_data).encode()prepared.headers[Content-Length]str(len(prepared.body))# 或使用 prepare_body 方法重新处理坑3prepared.headers 是 CaseInsensitiveDict# headers 的 key 大小写不敏感但 value 是字符串prepared.headers[content-type]# 等价于 prepared.headers[Content-Type]# 但 Session.send() 后 headers 可能被再次修改测试验证importpytestimportrequestsclassTestPreparedRequest:验证 PreparedRequest 功能deftest_prepare_basic_get(self):srequests.Session()reqrequests.Request(GET,https://httpbin.org/get,params{q:test})prepareds.prepare_request(req)assertprepared.methodGETassertqtestinprepared.url s.close()deftest_prepare_includes_implicit_headers(self):srequests.Session()s.headers[X-Custom]session-levelreqrequests.Request(GET,https://httpbin.org/get)prepareds.prepare_request(req)assertHostinprepared.headersassertprepared.headers[X-Custom]session-levels.close()deftest_prepare_and_send(self):srequests.Session()reqrequests.Request(GET,https://httpbin.org/get)prepareds.prepare_request(req)resps.send(prepared)assertresp.status_code200s.close()deftest_template_reuse(self):srequests.Session()reqrequests.Request(POST,https://httpbin.org/post,headers{Content-Type:application/json})templates.prepare_request(req)importjsonforiinrange(3):template.bodyjson.dumps({i:i}).encode()resps.send(template,timeout10)assertresp.status_code200assertresp.json()[json][i]i s.close()4. 项目总结核心知识点步骤API作用构建 RequestRequest(method, url, **kwargs)收集所有请求参数预编译session.prepare_request(Request)执行编码/合并/序列化审计prepared.url/headers/body查看最终请求内容签名修改prepared.headers插入签名/认证信息发送session.send(prepared)通过 Session 发送优点 缺点对比维度requests.get()PreparedRequest send()批量场景性能每次完整构建模板复用可见性审计不透明完全透明签名/认证窗口不方便黄金窗口代码复杂度极简略高适用场景批量 API 调用同模板不同参数请求签名HMAC/AWS SigV4/OAuth安全审计查看最终发出的完整请求调试PreparedRequest 转 curl 命令注意事项修改prepared.body后需手动更新Content-LengthSession.send()会再次处理prepared如认证 hook不是简单的发送PreparedRequest 的path_url不包含 host只含 path query常见踩坑经验案例一批量发送时 Content-Length 不更新。开发用模板发送 100 条消息替换了 body 但忘了更新 Content-Length。前 50 条数据都正确因为 body 长度恰好一样第 51 条数据更长——服务端只读到了前 50 条数据的长度数据被截断。根因body 长度变化后 Content-Length 未更新。修复每次替换 body 后prepared.headers[Content-Length] str(len(prepared.body))或调用prepared.prepare_body()。案例二PreparedRequest 的 headers 被 Session 再次修改。开发在 PreparedRequest 中正确设置了所有 headers但Session.send()执行时 Session 的auth钩子修改了 Authorization 头。根因Session.send() 不是纯粹的网络发送它还会执行 Session 级别的 hooks 和 auth。修复理解 Session.send() 的完整流程在签名后设置不可覆盖的标记。案例三PreparedRequest 模板并发安全问题。批量发送使用了多线程所有线程共享同一个 PreparedRequest 模板但各自替换 body——在并发环境下产生了竞态条件线程 A 的 body 被线程 B 覆盖。根因PreparedRequest 不是线程安全的。修复每个线程独立创建 PreparedRequest 实例或使用deepcopy。思考题设计题设计一个请求重放工具——将 PreparedRequest 序列化为 JSON/YAML 文件支持在另一个环境中加载并重新发送。需要处理的问题序列化 headersCaseInsensitiveDict、序列化 body可能是 bytes、反序列化后如何重新发送Session 不存在了。扩展题如何在 PreparedRequest 的基础上实现请求的乐观重试——如果预测到请求可能会失败基于历史模式在 prepare 阶段就生成一个备选 PreparedRequest指向备用服务器或降级路径并在主请求失败时自动切换到备选延伸阅读与资源Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析