10. 产品化:账户、配额、队列、多副本部署#
一句话结论:这套 Agent 只有一种合法形态——MySQL + Redis + 「API 入队 / worker 执行」两个进程,多副本下的配额、归属、去重三样真相全在库和 Redis 里,进程内不留真相。
版本基准:HEAD
634f4ab(2026-09-22)。与 2026-09-16 旧版相比,本章核心事实已反转:旧版写「默认 SQLite、单进程直跑 AgentLoop、横向扩展无产物」,现在是 MySQL 必填 + 起服形态闸(app/deployment.py)、队列恒开、多副本四场景在 gcjp 验收通过。
速背卡#
| # | hint(≤15 字) | 展开一句(≤40 字) |
|---|---|---|
| 1 | 三道跨进程闸:holds / 深度 / worker | 用户级并发 MAX_CONCURRENT_RUNS=3、QUEUE_MAX_DEPTH=200、WORKER_CONCURRENCY=4 |
| 2 | 进程内准入池已删 | API 一个 AgentLoop 都不跑,那个数不对应任何真实资源(0978dc1) |
| 3 | 真相三搬家:额度 / 归属 / 指纹 | 分别搬进 run_holds 表、threads 条件更新、Redis SET NX EX |
| 4 | 先预扣,后判幂等 | 占槽那段必须无 await,幂等命中的三条路各自还预扣 |
| 5 | 掐断收尾顺序:事件→还位→写终态→ack | 先写终态再还占位,用户重发会撞 already_running |
| 6 | grace 330 > 单轮超时 300 | 旧默认 120 比一轮还短,等于每次发布主动掐一批任务 |
| 7 | 四场景:dup / kill-worker / kill-redis / rolling | gcjp 上 2 API + 2 worker 四绿(a2b4ed8) |
30 秒版#
一个 Agent 要上公网,外面包了四层:bcrypt+JWT 的账户、按美元成本记的每日 credit 配额、Redis Stream 队列削峰、跨进程的事件背板与控制面。2026-09-19 的阶段 1 把它从「单进程能跑」改成「只能多副本跑」:额度改成先预扣后结算的 run_holds 表、同 thread 唯一性改成 threads 表的一条条件 UPDATE、去重窗口搬进 Redis,起服时用形态闸拒绝 SQLite。
3 分钟版#
- 为什么要闸。 一轮几十秒、按 token 花钱。开放注册后三种打穿法:一个人连发 N 条、脚本注册一堆号、长任务占满并发。
- 配额按
cost_usd算不按 token 数(app/db/quota.py)。对外只显示整数 credit(CREDITS_PER_USD = 1000,日额度DAILY_QUOTA_USD2.0 美元 = 2000 credit),UTC 自然日靠新插一行重置,没有定时任务。 - 额度闸从事后记账挪到进门(1-1
9ddd290)。run_holds一行一个 run:进门acquire_hold锁用户行、数在飞数、扣额度;跑完settle用条件更新做幂等,重投不双记。 - API 只入队,worker 才跑 AgentLoop(1-7
0978dc1)。QUEUE_ENABLED/CONTROL_ENABLED/BACKPLANE_ENABLED三个开关删掉恒开,进程内队列只留给单测注入。 - 关停不再静默重跑(1-3
c15ac5b)。排空超时的任务当场给定论:发task_interrupted→ 还占位 → 写interrupted→ ack,不再留 PEL 等十分钟后没人看的重跑。 - 多副本验收台(1-5
a2b4ed8):docker/docker-compose.multi.yml起 2 API + 2 worker + MySQL 8 + Redis,用scripts/stub_agent.py替掉真run_agent,四个场景全绿。 - 切 MySQL(1-6
f9efe2b):搬数脚本 + 首启迁移串行锁;再补参数覆盖的每进程对账(1-883378ac),因为改配置的是 API 进程、推理在 worker。
它解决什么问题#
1. 一个人并发发 N 条,额度闸看不见
- 坏法:事后记账。N 条同时进门都读到满额全部放行,跑完一起记账已经超支;账本没有
run_id,PEL 重投整轮重跑就是双份账单。超支不报错,只有月底账单看得见。 - 修法:
run_holds预授权表(app/db/holds.py)。acquire_hold先FOR UPDATE锁用户行、数在飞数(超MAX_CONCURRENT_RUNS=3→ 429 + Retry-After)、扣额度(≤0 → 402),再落行;settle的WHERE state IN (active)条件更新就是幂等判据,与账本累加同一个事务。 - 代价:预扣只能排在幂等判定之前(占槽那段必须无
await),所以幂等命中的请求先占后还;kill -9留下的行靠expires_at(HOLD_TTL_SEC900s)过期,不加扫表协程。
2. 多副本下「同 thread 谁在跑」给不出答案
- 坏法:
active_tasks字典 +dedup._recent都在 API 进程内存。同一个 thread 打到两台各起一个 run;换thread_id的重放打到两台各跑一遍。单进程下这两个机制「看着都是对的」。 - 修法(1-2
b79efb7):threads加active_run_id/run_status/active_query三列(迁移 0014),认领是一条条件 UPDATE,判定与占位同一条语句,数据库保证并发两条只有一条影响行数为 1,三种结局started/already_running/replaced分开返回。去重窗口改 RedisSET NX EX,查与登记原子一步。 - 代价:
threads的外键不得不去(鉴权关闭时user_id是假身份,带外键登记不进来),「token 的 sub 查无此人 → 401」改由claim_thread显式查users表兑现;Redis 不可达直接 503,没有进程内回退;进门即登记,被 429 /already_running/ duplicate 拒掉的三条路各自_rollback_claim(一次还三样:预扣、在跑位置、指纹)。
3. 滚动发布把任务掐成静默重跑
- 坏法:不 ack、留 PEL、十分钟后
XAUTOCLAIM领回重跑。那时用户页面早关了,事件推给没人听的 thread,模型调用却是真的再花一次(settle只挡同一笔结算跑两遍,挡不住同一条 query 真跑两遍)。 - 修法(1-3
c15ac5b+581979e):WORKER_GRACE_SECONDS120 → 330(旧默认比单轮超时MAIN_AGENT_TIMEOUT_SEC300 还小);排空超时的当场收尾并 ack。TaskState加interrupted+TERMINAL_STATES单点定义,前端显示同名状态并提示「重发一次」,不自动重发(多副本同时中断会在同一秒打出一波重试)。 - 代价:用户要手动重发一次;收尾整体限时
WORKER_INTERRUPT_FINALIZE_SEC5s,超时照样 ack(走到那里说明 DB/Redis 已经不好使)。
4. 两个进程各算各的
- 坏法一:产物。
output/与uploaded/各按项目根算,worker 写、API 读,不在同一文件系统位置时读侧一律 404 且不报错,表现为「产物莫名其妙没了」。 - 修法一(1-4
e9022bb):统一从ARTIFACT_ROOT派生(默认项目根,本地行为不变),path_utils自己load_dotenv一次;prod compose 补 worker 服务,三个卷与 backend 逐字相同,stop_grace_period360s。 - 坏法二:配置。后台改参数写的是 API 进程的
os.environ与模块级常量,worker 照旧拿旧值推理,前端回显还显示「已生效」,不报错、没日志。 - 修法二(1-8
83378ac):store.refresh()每 30s 与库对账,两个方向都走(库有内存没有 → apply;内存有库里没有 → reset 回.env基线)。不用控制面广播:配置是状态不是事件,publish 失败只打 warning,worker 重连期间错过一条就永远是旧值;轮询丢了下一轮自己补。
5. 误配是静默的
- 坏法:
DATABASE_URL指 SQLite 时,每台副本各有一份 users / threads / run_holds,配额、归属、「同 thread 只跑一个」三样全部各算各的,没有任何报错;Redis 不可达时任务入不了队,用户对着 running 等到超时。 - 修法(1-7
0978dc1):app/deployment.py的形态闸,API 的 lifespan 与 worker 的amain共用——非 MySQL 或队列 Redis 不可达直接拒绝启动,WORKER_GRACE_SECONDS < MAIN_AGENT_TIMEOUT_SEC只警告。放在app/顶层,是为了 worker 不必为一道校验把整个 FastAPI 模块拖进来。 - 代价:本地手工起后端要先
docker compose -f docker/docker-compose.multi.yml up -d mysql redis;单元测试不受影响(ASGITransport 不跑 lifespan)。
机制怎么跑#
一次 POST /api/task 穿过的闸(app/api/server.py 的 create_task,顺序即代码顺序):
- 身份:
AUTH_ENABLED时只取 JWT 的sub,忽略请求里的user_id(resolve_identity)。 - 配额
_enforce_quota(server.py:350):今日额度用尽 → 402,不占 thread、不占槽、不入队。 - 归属
_claim_thread_if_needed(server.py:707):claim_thread自带属主校验,拿别人的 tid 发消息 → 403。 - 分档
classify_request(turn_count):按该 thread 历史轮数分 normal / heavy,是预扣 credits 与分流到哪条 Stream 的共同依据(app/api/concurrency.py现在只剩分档)。 - 深度背压
_queue_depth_or_429(server.py:714):depth() >= QUEUE_MAX_DEPTH(200,server.py:128)→ 429 + Retry-After。 - 预扣
_acquire_hold_or_reject(server.py:722):并发超限 429 / 额度耗尽 402。排在幂等判定之前,因为下面那整段必须一个await都没有。 - 幂等第 1 层
claim_thread_run(app/db/runs.py):同 query →already_running(还掉预扣、领回原任务);换 query →replaced,带回旧run_id,取消按它送(旧 run 可能在另一台副本)。 - 幂等第 3 层
dedup.check_duplicate:只对不自带thread_id的客户端(脚本 / 裸 API)生效。前端是 connect-first、tid 存 localStorage,并进别的 thread 会让它那条 WS 一个字都收不到。Redis 不可用 → 503,不静默吞。 - 入队:
XADD到globex:intents或:large,立即返回thread_id;API 只登记一个等结果的影子协程。 - worker 侧(
app/worker.py):消费者组领消息 →handle_task→run_agent→ 成功才 ack;并发由信号量控在WORKER_CONCURRENCY(4,worker.py:58)。 - 没人领(4-1
307d12d):queued超QUEUE_START_TIMEOUT_SEC(60s,server.py:141)判定无 worker 在领 → 发queue_timeout、先落取消标记再复查状态、还预扣/占位/指纹、终态写failed(可重发,不是 cancelled)。没有控制面时不作废——标记落不下去就拦不住 worker,此时报超时是骗用户。 - 关停(
run_worker):SIGTERM → 停止领新任务 → 最多等WORKER_GRACE_SECONDS(330,worker.py:62)→ 超时取消,每条走_finalize_interrupted(worker.py:79)。
掐断收尾顺序(可背):task_interrupted 事件 → 还 thread 占位(跑到 run_agent 之前被掐的另还预扣)→ 写 status=interrupted → ack。占位必须在写终态之前还:等结果那方一见终态就放手、用户下一秒可能重发,占位还挂着时写终态,重发会被自己刚被掐掉的那轮挡成 already_running。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| ~2026-09-15 前 | (旧版核对过) | 账户 bcrypt+JWT、credit 配额、注册限流、Redis Stream 队列、背板 / 事件回放 / 澄清令牌 / 控制面 | 上公网 demo 的基础层,当时全部默认关,单进程 demo 行为不变 |
| 09-19 | 8b3b2d4 / 3b3790e | k6 脚本跑 connect-first 全链路,替掉 Python 压测客户端 | 500 并发实测 5~6s 延迟全来自 Python 客户端与 macOS backlog,不是服务端真延迟 |
| 09-19 | 9ddd290(1-1) | run_holds 预授权表 + 按 run_id 幂等结算 + 用户级并发上限 | 事后记账拦不住并发透支,重投会双记 |
| 09-19 | b79efb7(1-2) | 同 thread 唯一真相进 threads 条件更新;dedup 窗口进 Redis | 两者原先都在 API 进程内存,多副本下给不出答案 |
| 09-19 | c15ac5b / 581979e(1-3) | grace 120→330;掐断按 interrupted 收尾并 ack;k8s terminationGracePeriodSeconds 150→365 | 旧 grace 比单轮超时还小;重跑发生在十分钟后没人看,钱却真花 |
| 09-20 | e9022bb(1-4) | 产物根统一 ARTIFACT_ROOT;prod compose 补 worker 服务 | worker 写、API 读,不同路径时读侧静默 404 |
| 09-20 | a2b4ed8(1-5) | docker-compose.multi.yml + scripts/stub_agent.py + 四场景,在 gcjp 四绿 | 1-1~1-4 全是多副本正确性,单进程测不出来 |
| 09-20 | f9efe2b(1-6) | SQLite→MySQL 搬数脚本 + 首启迁移串行锁(GET_LOCK / advisory lock) | 四容器同时 upgrade head,MySQL DDL 非事务,撞 1050 留半成品表 |
| 09-20 | 0978dc1(1-7) | 删进程内双池准入与直跑分支;三个开关恒开;加 app/deployment.py 形态闸 | API 不跑 loop,那个并发数不对应任何真实资源;误配全是静默的 |
| 09-20 | 2ad5212 | prod 两个 build 段补 UV_EXTRAS=db | 起服闸要 MySQL,:latest 镜像里却没有 aiomysql;验收台用 :multi,照不出这个洞 |
| 09-20 | 83378ac(1-8) | 参数覆盖改成每进程与库对账(30s 轮询) | 拆成两个进程后,管理员改的是 API,推理在 worker |
| 09-21 | 307d12d(4-1) | 入队等待上限 60s,超时连消息一起作废 | 老行为干等 30 分钟才报超时,消息仍被重投静默跑一遍 |
| 09-21 | 55bf774 | docker-compose.loadtest.yml 叠加层,只给 mysql / redis 开宿主高位端口 | multi.yml 本身不开对外端口(场景脚本靠 docker exec 发请求) |
| 09-21 | 83f6678(减法 C1) | server.py 1779→1106 行,文件 / 偏好 / 订单拆成三个 router,守卫进 guards.py | 三摊与任务主线无关的事挤在主文件里;路由数拆前拆后都是 33,路径不变 |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
MAX_CONCURRENT_RUNS 3 | 一个用户同时能有几个 run 在飞 | app/db/holds.py:51 | ✓ 代码 |
QUEUE_MAX_DEPTH 200 | 队列积压上限,超了 429 | app/api/server.py:128 | ✓ 代码 |
WORKER_CONCURRENCY 4 | 单个 worker 同时跑几轮 | app/worker.py:58 | ✓ 代码 |
WORKER_GRACE_SECONDS 330 = 300 + 30 | 排空窗口,须 ≥ MAIN_AGENT_TIMEOUT_SEC 300 | app/worker.py:62、app/deployment.py | ✓ 代码 |
k8s terminationGracePeriodSeconds 365 | = preStop 5 + grace 330 + 收尾余量 30 | deploy/k8s/50-worker.yaml(581979e 正文) | ✓ 提交正文 |
WORKER_INTERRUPT_FINALIZE_SEC 5 | 每条被掐任务的收尾限时 | app/worker.py:66 | ✓ 代码 |
QUEUE_START_TIMEOUT_SEC 60 | 入队后多久没人领就作废 | app/api/server.py:141 | ✓ 代码 |
HOLD_TTL_SEC 900 / THREAD_STALE_RUN_SEC 900 | kill -9 后预扣与 thread 占位多久自然失效 | app/db/holds.py:55、app/db/runs.py:53 | ✓ 代码 |
DAILY_QUOTA_USD 2.0 / CREDITS_PER_USD 1000 | 每人每天 2 美元 = 2000 credit | app/db/quota.py:46,51 | ✓ 代码 |
DB_MIGRATION_LOCK_TIMEOUT 120s | 等迁移锁的时长,超时后库在 head 就放行、否则拒绝启动 | app/db/session.py:239 | ✓ 代码 |
| 四场景全绿(dup / kill-worker / kill-redis / rolling) | 2 API + 2 worker + MySQL + Redis,在 gcjp 上跑 | a2b4ed8 正文 | ✓ 提交正文 |
| 15 张表行数全一致(含 740 行 messages) | SQLite→MySQL 搬数脚本的验证 | f9efe2b 正文 | ✓ 提交正文 |
| 本机 k6 三档 100/200/500:吞吐 15.5 / 15.9 / 19.9 req/s,终态 P50 3.50 / 6.55 / 12.97s,0 失败 | 台子 = 2 容器 + 2 本地 stub 进程,AUTH_ENABLED=false、WORKER_CONCURRENCY=64(人为放大) | 2026-09-21 本机跑,未在仓库产物核对 | 仅口径,不代表线上配置 |
| 500 档首事件 P50 拆段:POST 2.05s / 排队 6ms / 投递 10ms | 瓶颈在 POST /api/task 入队(约 4ms/请求,近乎线性),不在队列排队 | 同上 | 仅口径 |
| 迁移 16 条(0001 accounts → 0016 drop_preferences) | migrations/versions/ | ✓ 代码 |
账户与跨进程四块(旧版核对过,代码复核仍在)#
账户:app/db/accounts.py 用 bcrypt 存密码,密码长度上限 MAX_PASSWORD_BYTES = 72(bcrypt 超出部分被静默忽略,不拦住的话 100 字符强密码等价于它的前 72 字节)。AUTH_ENABLED 开启后身份只取 JWT 的 sub(JWT_EXP_SECONDS 默认 86400,app/api/auth.py:88),访问他人会话 403;关闭时账户接口返回 404。
注册限流(app/api/ratelimit.py)两道闸:IP 滑动窗口在进程内存(注册每小时 5 次 REGISTER_PER_IP_PER_HOUR、登录每 15 分钟 20 次 LOGIN_PER_IP_PER_15MIN,重启清零),全局每日新增用户上限 MAX_NEW_USERS_PER_DAY 50 直接 COUNT users 表,超了 429。多副本下前者变成「每副本各算一份」,是已知的松口径。
跨进程补的四块(拆成两个进程后各自必须存在,现在都恒开):
| 问题 | 模块 | 做法 | 出错时怎么退 |
|---|---|---|---|
| 事件在 worker 产生、WS 连在 API 进程 | app/api/backplane.py | 发 Redis Pub/Sub 频道 globex:agui;每进程带随机 origin,收到自己发的就跳过(否则多副本每条事件出现 N 遍) | 静默丢实时事件;收尾结果仍可查、历史仍入库 |
| 断线重连期间的事件缺口 | app/api/event_log.py | 每 thread 一条 Stream,EVENT_REPLAY_MAXLEN 200、EVENT_REPLAY_TTL 6h、每次 append 刷新 TTL;重连带 ?last_event_id= 补发再转直播 | 包在熔断器里,退回只直播(EVENT_REPLAY 默认开) |
ask_user 的 Future 在 worker 内存,回复进 API 进程 | app/api/clarification.py | worker 建 Future 时往 Redis 写等待令牌,API 落空再读令牌、经控制面广播 {reply_id, text},worker 比对 reply_id 才 resolve | 令牌过期按取消处理,迟到的回复拒收 |
| 取消指令找不到在哪个 worker | app/api/control.py | Pub/Sub globex:control 广播给已领走任务的 worker;还没被领走的靠按 task_id 的取消标记 | 按 task_id 不按 thread 打标记,否则误伤同 thread 上刚入队的新任务 |
部署三处:公网 demo docker/docker-compose.prod.yml(qdrant / opensearch / redis / mysql / backend / worker / frontend,两个 build 段都传 UV_EXTRAS=db);多副本验收台 docker/docker-compose.multi.yml(2 API + 2 worker + MySQL 8 + Redis,镜像 tag :multi,不发布端口)+ 压测叠加层 docker/docker-compose.loadtest.yml(只开 13306 / 16379);K8s deploy/k8s/ 六个 yaml,api 与 worker 同镜像不同 command、同一个 PVC 挂 artifacts。
追问 10 题#
- 问:为什么预扣要排在幂等判定前面,不是更浪费吗? 因为幂等判定到入队那一整段必须无
await(单线程事件循环里没有await就不会被别的请求插进来),而预扣要查库。代价是幂等命中的请求先占后还,三条返回路各自 release。证据:app/api/server.pycreate_task注释、9ddd290 正文。 - ⚠ 问:同 thread 双击是怎么挡住的? 不是靠字典,是靠
threads表一条条件 UPDATE——判定与占位在同一条语句里,两台副本只有一条影响行数为 1。active_tasks降级为本进程缓存(只服务/inflight、取消口、影子协程身份)。证据:app/db/runs.py、b79efb7 正文。 - ⚠ 问:被掐断的任务为什么不重投? 重投发生在十分钟后,用户页面早关了,事件推给没人听的 thread,而整轮重跑的模型调用是真的再花一次——
settle的幂等只挡同一笔结算跑两遍,挡不住同一条 query 真跑两遍。证据:c15ac5b 正文。 - 问:interrupted 收尾顺序为什么不能换? 占位必须在写终态之前还,否则用户看到终态立刻重发会撞
already_running,被自己刚被掐的那轮挡住。有一条测试专门断言这个顺序。证据:c15ac5b 正文。 - 问:去重窗口 Redis 挂了为什么不降级回进程内? 降级的方向按「丢了什么」定:事件丢了只是少看几条,任务重跑是真花钱,所以去重直接 503。同理
enqueue失败抛错,只有depth()这种纯观测吞异常。证据:app/api/dedup.py、b79efb7 正文。 - 问:配置热更新为什么用 30s 轮询而不用控制面广播? 配置是状态不是事件。广播是 fire-and-forget,worker 重连期间错过一条就永远是旧值;轮询每轮重新对账,丢了下一轮补回来。取消指令必须实时才走广播。证据:83378ac 正文。
- 问:迁移锁为什么用
GET_LOCK而不是在表里插一行? 命名锁是会话级的,连接一断两种库都自动释放;锁表插行的话进程被 SIGKILL 就留下一把永远解不开的锁。还有一处细节:探库必须挪进锁里面,锁外探到的表清单一拿到锁就过期,legacy 判断会判反。证据:f9efe2b 正文、app/db/session.py。 - ⚠ 问:起服闸为什么放
app/deployment.py而不是app/api/? 两个进程入口共用一份,各写一份迟早漂成「API 起得来、worker 起不来」;放顶层是为了 worker 不必为一道校验把整个 FastAPI 模块(连同 agent / tools 链)拖进来。证据:app/deployment.py模块 docstring。 - 问:多副本验收为什么要用桩? 真
run_agent一轮几十秒且花钱,验的又是编排正确性不是模型质量。桩不进主链路代码——没有AGENT_STUB_SLEEP这类 env 开关,生产误开就是全站假回答且不报错;容器把 command 指到scripts/stub_agent.py即可。证据:a2b4ed8 正文。 - ⚠ 问:四场景全绿,为什么还漏了 prod 镜像没装驱动? 验收台用
:multi镜像(传了UV_EXTRAS=db),prod 用:latest(没传),两条构建路径分叉,要到新形态上线第一秒才暴露。另一条同源的:关着鉴权跑验收时 holds 全 noop、run_holds是空表,1-1 那几刀一条都验不到。证据:2ad5212、a2b4ed8 正文。
坑与易混点#
CLAUDE.md第 7 节仍写「长期记忆 Store 随DATABASE_URL走 SQLite」,与 1-7 起「SQLite 只留作单元测试 fixture」的形态闸冲突(未改代码,只记在这)。scripts/loadtest_stub_server.pydocstring 里「一条命令起单进程」已跑不通:形态闸要 MySQL 且无开关,队列模式下还必须另起 worker 进程。- 压测数吞吐对应的是
WORKER_CONCURRENCY=64(线上默认 4),且AUTH_ENABLED=false时 holds 全 noop——这组数不能当线上容量说。 MCP_SEARCH_*环境变量名是历史遗留,单环之后没有 search 角色,只是 MCP 只读白名单的配置前缀。- 覆盖重发按 DB 读回的旧
run_id送取消,不是按本进程active_tasks里的 task——旧 run 可能是另一台副本收的,按本地送就是送了个空。
本章和别章的接口#
- 写边界的幂等(确认卡
operation_id/ 按run_id的订单幂等)在第 3 章。 - 单轮超时
MAIN_AGENT_TIMEOUT_SEC与终止安全在第 2 章;延迟拆段与 SLO 口径在第 9 章。 - Rubric 评测与 bad case 采集(
evolution/collector.py读的也是ARTIFACT_ROOT)在第 8 章。