对 Python 应用场景的一次重新思考:FastAPI、协程、线程、数据库与任务系统边界

最近在重新设计一个任务系统时,我顺便把自己对 Python,尤其是 CPython 应用场景的理解重新梳理了一遍。

这次讨论的背景是一个典型的异步任务服务:

上游提交任务
API 立即返回 task_id
后台 worker 慢慢执行
用户通过 task_id 查询任务状态

任务主要是 LLM 调用、图片下载、外部 HTTP 请求这类 I/O 型工作。

一开始关注的是队列、Redis、PostgreSQL、worker 并发控制这些问题。但聊到后面,其实更核心的问题变成了:

Python 到底应该放在什么位置?
哪些并发适合 Python?
哪些并发不要硬塞给 Python?
FastAPI、协程、线程、数据库之间应该怎么分工?

这篇文章就是这次思考的整理。


一、我不想抛弃 Python,但也不想陷入 Python 多进程

Python 的开发效率很高,尤其是在业务编排、API、数据处理、调用外部服务这些场景里,仍然非常舒服。

我并不想简单地说:

以后全部换 Go

这不现实,也没必要。

但我也越来越清楚地意识到,在 CPython 里,如果一开始就把系统设计成多进程、多 worker、多层并发叠加,很容易让心智负担变重。

比如:

uvicorn --workers 4
每个 worker 内部又有 asyncio.Semaphore
每个进程还有自己的内存队列
后台又 create_task

这种设计一旦上了多进程,就会出现一个很麻烦的问题:

你以为自己限制了并发,其实只限制了某一个进程里的并发。

比如每个进程限制 20:

4 个进程 × 每个 20 = 实际 80

这不是 Python 独有的问题,但在 FastAPI + CPython 的场景里很容易发生。

所以我现在更倾向于一个收紧后的原则:

只要还在 CPython 里:
API 层用 FastAPI
并发尽量在单进程里处理
具体选择协程还是线程
不要优先碰 Python 多进程

二、FastAPI 适合做什么?

FastAPI 非常适合作为 API 层。

它适合做:

参数校验
权限校验
请求路由
写数据库
查数据库
调用轻量外部接口
返回响应

也就是说,FastAPI 最适合承担:

请求入口层

它不应该承担太多重任务。

尤其是下面这种任务,不应该直接压在 API 进程里:

几十秒的 LLM 调用
长时间图片下载
视频生成
复杂后台处理
需要失败恢复的任务

对于这类任务,更合理的方式是:

POST /tasks 只负责创建任务
后台 worker 负责执行任务
GET /tasks/{id} 负责查询任务状态

API 层应该尽量短、快、无状态。

这也是我现在对 FastAPI 的定位:

FastAPI 负责接单,不负责干重活。

三、请求内并发:适合用协程

如果一个接口本身需要同时调用多个外部服务,比如:

GET /analyze
  ↓
同时调用 model A
同时调用 model B
同时调用 model C
  ↓
聚合结果返回

这种就是典型的请求内 I/O 并发。

它非常适合用:

asyncio
asyncio.gather
httpx.AsyncClient
asyncpg

例如:

results = await asyncio.gather(
    call_model_a(),
    call_model_b(),
    call_model_c(),
)

这类场景里,Python 协程是舒服的。

因为任务的大部分时间不是在占用 CPU,而是在等网络、等数据库、等外部 API 返回。

这正是 asyncio 擅长的场景。


四、长任务不要直接塞进 API 进程

另一类场景是后台任务。

比如:

POST /tasks
  ↓
立即返回 task_id
  ↓
后台几十秒后完成

这种任务就不适合在 FastAPI 里直接:

asyncio.create_task(process_task(...))

这么写看起来简单,但问题很多:

任务藏在 API 进程内存里
进程挂了任务可能丢
多进程后并发倍增
任务状态和执行状态容易分裂
无法优雅做超时回收

所以我的原则是:

长任务不在 API 进程里直接执行。

更稳的结构是:

FastAPI:
    创建任务,写入 PostgreSQL / Redis

Worker:
    独立单进程,消费任务,执行任务

PostgreSQL / Redis:
    保存任务状态,控制全局并发,支持恢复

也就是说,即使坚持 Python 单进程,也应该让 API 和 worker 分层,而不是全塞进同一个 FastAPI 进程。


五、Python worker:单进程优先

对于 worker,我现在的倾向是:

Python worker 保持单进程

然后在单进程内部选择:

协程

或者:

线程

不要一上来就用 Python 多进程。

一个比较清晰的 worker 模型是:

1 个 Python worker 进程
  ↓
1 个 asyncio event loop
  ↓
N 个 coroutine worker
  ↓
每个 coroutine 循环领取任务、执行任务、写结果

比如:

WORKER_CONCURRENCY=5

表示当前这个 Python worker 进程内部最多同时跑 5 个任务。

如果需要更高吞吐,可以启动多个容器,但不要在一个 Python 容器里再搞多进程套多协程。

更推荐保持结构简单:

一个 worker 容器 = 一个 Python 进程
一个 Python 进程 = N 个协程

全局并发则不要交给 Python 进程内机制,而交给 PostgreSQL 或 Redis。


六、协程和线程怎么选?

这是这次讨论里非常重要的结论。

我现在会用一个简单规则判断:

能 await 的,用协程。
不能 await 但会阻塞的,用线程。
CPU 重计算,不适合 CPython 单进程并发。

1. 能 await 的,用协程

适合:

HTTP 请求
LLM API 调用
图片下载
文件上传下载
数据库异步驱动
等待外部服务返回

常用技术栈:

httpx.AsyncClient
asyncpg
aiofiles
asyncio.gather
asyncio.Semaphore
asyncio.Queue

这类任务大部分时间在等待 I/O,不需要一直占用 CPU。

所以协程非常合适。


2. 同步阻塞库,用线程兜底

有些库没有 async 版本,比如:

requests
某些云厂商 SDK
某些同步 LLM SDK
阻塞式文件操作
轻量图片处理

如果这些函数会阻塞 event loop,就应该放到线程里:

result = await asyncio.to_thread(sync_func, arg1, arg2)

或者:

loop = asyncio.get_running_loop()
result = await loop.run_in_executor(executor, sync_func)

线程在这里的作用不是让 Python 做 CPU 并行,而是:

别让同步阻塞函数卡住整个 event loop。

这是线程在 Python async 系统里的一个很实用的位置。


3. CPU 密集型任务,不要硬塞进 CPython 单进程

如果是:

大量图像计算
视频编码
本地模型推理
复杂压缩/解压
大规模 CPU 计算

那就不要指望:

FastAPI 单进程 + 线程

能优雅解决。

CPython 有 GIL,线程很难真正发挥多核 CPU 的并行能力。

这类任务应该考虑:

拆成独立服务
用 Go / Rust 重写
调用外部命令
交给 GPU 服务
单独拆 CPU worker

核心原则是:

不要让 CPython 承担它不擅长的位置。

七、爬虫:非常适合 Python 单进程多协程

爬虫是一个很典型的例子。

很多爬虫任务本质上是 I/O 密集型:

请求网页 -> 等网络
下载图片 -> 等网络
调用代理 -> 等网络
写数据库 -> 等 I/O

这些等待时间很长,而 CPU 实际工作时间不一定多。

所以 Python 单进程多协程非常适合爬虫。

一个典型结构是:

1 个 Python 进程
1 个 event loop
N 个抓取协程
每个协程负责领取 URL、请求、解析、写回结果

例如:

async def crawl_worker():
    while True:
        url = await get_next_url()
        html = await fetch(url)
        await save_result(url, html)

如果使用:

httpx.AsyncClient
aiohttp
asyncpg
asyncio.Semaphore

就可以在一个进程里同时处理大量等待网络返回的任务。

比如:

全局抓取并发 100
单域名并发 5
请求超时 10 秒
失败最多重试 3 次
结果写 PostgreSQL

这种模型对 Python 来说非常自然。

但这里也有边界。

如果爬虫后面有大量 CPU 工作,比如:

复杂文本抽取
大量图片处理
PDF 解析
OCR
视频处理
本地模型推理

那它就不再是单纯的 I/O 密集型任务了。

更合理的方式是拆开:

async 爬虫负责下载
CPU worker 负责解析
数据库 / 队列负责连接两段流程

不要把下载、解析、计算、存储全部混在一个 Python event loop 里硬扛。


八、单进程多协程不等于完整任务系统

Python 单进程多协程解决的是:

怎么同时等待很多 I/O。

但它不解决:

任务状态怎么管理?
任务失败怎么重试?
哪些任务已经处理过?
哪些任务正在处理?
进程挂了怎么恢复?
系统级并发怎么控制?

这些问题不能只靠 asyncio。

比如爬虫场景里,真正做稳以后会遇到:

哪些 URL 已经抓过?
哪些 URL 正在抓?
哪些 URL 抓取失败?
失败后要不要重试?
同一个域名最多同时抓几个?
全局最多同时抓几个?
抓到的数据保存在哪里?
进程挂了以后怎么恢复?

这些问题不适合只放在 Python 内存里。

更成熟的做法是引入 PostgreSQL 或 Redis 这类外部状态组件。

PostgreSQL 可以负责:

url 队列表
任务状态
去重
失败次数
抓取结果
下次重试时间
全局并发协调

Redis 可以负责:

高速队列
短期去重
限速计数器
分布式锁
域名级并发控制

这样 Python 协程就不用承担所有职责。

Python 只负责:

领取任务
发请求
解析结果
写回状态

数据库或 Redis 负责:

任务事实来源
状态持久化
并发协调
失败恢复

这点非常重要。

成熟的 Python 异步架构不是单纯靠 asyncio,而是:

asyncio + 外部状态管理

一句话概括就是:

协程负责并发执行,数据库负责秩序。

九、适当引入数据库,反而能让 Python 更简单

以前可能会觉得,引入 PostgreSQL / Redis 是增加复杂度。

但在任务系统里,适当引入数据库,反而是在降低 Python 代码的复杂度。

因为很多东西如果不用数据库托管,就会落到 Python 进程内存里:

内存队列
内存 set 去重
内存 semaphore
内存 retry 记录
内存 running 状态

这在单进程短任务里可以,但一旦服务重启、任务失败、worker 扩容,就很容易乱。

如果把这些状态交给数据库,Python 代码反而更专注:

我只负责从数据库领取一个任务
执行它
把结果写回去

比如 PostgreSQL 任务表可以设计成:

tasks
- task_id
- status
- request
- result
- error
- attempts
- locked_until
- created_at
- updated_at

worker 领取时用:

FOR UPDATE SKIP LOCKED
locked_until
attempts

Redis 方案也可以类似:

Redis Stream 负责任务队列
Redis Hash 负责任务状态
Redis ZSET 负责 running 任务
Lua 负责原子并发控制
XAUTOCLAIM 负责崩溃恢复

这些组件不是为了炫技,而是为了让 Python 不必自己承担“系统秩序”。

这就是我现在很认同的一个方向:

Python 适合做执行者和编排者。
数据库适合做状态中心和秩序中心。

十、本进程并发和全局并发要分清

这是最容易混的地方。

Python 里的:

asyncio.Semaphore
asyncio.Queue
threading.Lock
concurrent.futures.ThreadPoolExecutor

这些都是本进程内的工具。

它们控制的是:

当前 Python 进程里发生什么。

它们控制不了:

整个系统里所有 worker 一共跑了多少任务。

所以如果系统中有多个 worker 容器,或者未来可能扩展到多个实例,就必须把全局并发控制放到外部组件里。

比如 PostgreSQL:

tasks 表
FOR UPDATE SKIP LOCKED
pg_advisory_xact_lock
locked_until
attempts

或者 Redis:

Redis Stream
Redis Hash
Redis ZSET
Lua
XAUTOCLAIM

一句话:

Python 进程内工具负责局部并发。
PostgreSQL / Redis 负责全局并发。

这两个层次一定不能混。


十一、我现在对 Python 的定位

经过这次思考,我对 Python 的定位反而更清楚了。

Python 适合:

业务编排
API 服务
I/O 型任务
LLM 调用
外部 HTTP 集成
爬虫
数据清洗
胶水层
快速验证

Python 不适合:

复杂多进程调度
极致低内存 worker
CPU 密集型并行
高性能网关
大规模连接管理

这不是说 Python 差,而是说它应该放在合适的位置。

Python 的强项是:

表达力
开发效率
生态
业务组合能力

Go 的强项是:

低内存
高并发
单二进制部署
goroutine
服务端长期运行

所以更合理的路线不是二选一,而是:

Python 负责业务表达。
Go 负责更底层、更高性能、更轻量的执行层。

十二、我的 CPython 默认策略

以后只要是 CPython 项目,我会默认采用这个策略:

FastAPI:
    单进程优先
    async endpoint
    做 API 层,不做重任务

请求内 I/O 并发:
    asyncio.gather
    httpx.AsyncClient
    asyncpg

同步阻塞库:
    asyncio.to_thread
    ThreadPoolExecutor

长任务:
    不在 API 里直接 create_task
    交给独立 worker

Python worker:
    单进程
    内部 asyncio 并发

任务状态:
    放 PostgreSQL / Redis
    不放 Python 内存

全局并发:
    不靠 Python 进程内机制
    靠 PostgreSQL / Redis

爬虫 / LLM / 外部 API:
    非常适合单进程多协程

CPU 密集任务:
    拆服务
    Go / Rust / 外部命令 / GPU 服务

这个策略可以让我继续使用 Python,但不把 Python 推到它不舒服的位置。


十三、最终理解

这次思考之后,我觉得最重要的不是“Python 行不行”,而是:

Python 应该放在哪一层?

如果把 Python 放在:

API
业务编排
I/O 调用
爬虫下载
LLM 请求
胶水层

它依然非常强。

如果把 Python 放在:

复杂多进程任务调度
极致低内存 worker
CPU 并行计算

它就会开始别扭。

所以我现在不会简单地说“抛弃 Python”,而是会更精确地使用它。

对于 CPython,我的最终原则是:

FastAPI 做 API。
单进程优先。
I/O 并发用协程。
同步阻塞用线程。
长任务交给独立 worker。
任务状态交给 PostgreSQL / Redis。
全局并发交给 PostgreSQL / Redis。
CPU 密集任务不要硬塞给 Python。

这套边界清楚以后,Python 仍然是一个非常好用的生产力工具。

只是不能再用它硬扛所有问题。

真正成熟的理解不是“Python 能不能并发”,而是:

Python 协程负责并发执行。
数据库负责状态和秩序。
线程负责兜底阻塞库。
CPU 密集任务交给更合适的执行层。

这才是我现在对 Python 应用场景最清晰的认识。

Read more

把 Codex CLI 的登录态"搬"到一台新服务器

场景:你在一台老机器上早就登录好了 Codex CLI,现在开了台新服务器、装好了 codex,但它没登录。你不想在新机上重新走一遍 OAuth 网页授权(有时候服务器上根本打不开浏览器),只想把老机器上那份"已经登录好的身份"复制过去。 这篇讲的就是这个搬运动作的完整方法论——为什么能搬、怎么搬、有哪些坑。命令里所有隐私都用占位符,照着换成你自己的即可。 一、先理解一件事:Codex 的登录就是一个文件 这是整个操作的地基。Codex CLI(ChatGPT OAuth 登录模式下)的登录状态,不在什么系统钥匙串里,也不在环境变量里,就是家目录下一个单独的 JSON 文件: ~/.codex/auth.json 它长这样(字段名是真的,值我打码了): { "auth_mode": "

By ladydd

哨兵机制:让 Agent 一触即醒

0. 一句话点破本质 **让"等"发生在便宜的子进程里,让贵的 agent 只在有事时醒。**心跳解决"最迟多久必有人查岗",探针解决"事情一发生几乎立刻有人到场"——两个机制回答的是两个不同的问题,谁也替代不了谁。 1. 机制全貌:会自杀的轮询进程 + 宿主的"尸体通知" 我的实现只有两块积木: 积木一:一个有明确死法的后台循环 # 放行任务的同时,后台挂上(run_in_background) for i in $(seq 1 20); do 信号=$(ssh data "tmux capture-pane -t dna

By ladydd

Agent 心跳机制·设计与实现

0. 一句话点破本质 **心跳不是闹钟,是"带着完整世界快照的自我唤醒"。**闹钟只解决"什么时候醒";心跳真正要解决的是你点出的那个问题——醒来的那个瞬间,清楚自己是谁、任务到哪了、这一跳该干什么。我所有跑得好的心跳,提示词都写得像给一个失忆的陌生人看的;所有出过事的心跳,都是因为假设"我还记得"。 1. 第一性原理:为什么"醒来知道干啥"这么难 一个长期任务里的 agent 面临三重失忆: 1. 上下文会被压缩——多轮之后早期细节只剩摘要,心跳打进来时,那条心跳提示词可能是上下文里唯一高保真的任务描述 2. 世界在你睡着时变了——下属可能干完了、卡死了、跑偏了,你脑子里的"进度"从睡着那刻就开始过期 3. 任务本身会变—

By ladydd

我没手动映射 3000,公网为什么还能访问?一次 UPnP 误开孔复盘

写在前面:标题里的“自己打开”只是当时的主观感受。路由器没有失控,也不存在神秘穿透。真正发生的是:排障自动化从局域网主动调用了 UPnP AddPortMapping,路由器按协议新增了公网映射。 1. 原本的设计边界 家里的 Open WebUI 跑在一台 Ubuntu 主机的 Docker 中: 内网主机 192.168.x.x:3000 路由器上手动配置的入口是: 公网 TCP 13000 → 内网主机:3000 外部用户不直接访问家宽端口,而是先到云端 Caddy: 用户浏览器 → https://ai.example.com (云端 Caddy) → http://home.example.com:13000 (DDNS → 家宽公网

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号