面试知识库

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
6grace 330 > 单轮超时 300旧默认 120 比一轮还短,等于每次发布主动掐一批任务
7四场景:dup / kill-worker / kill-redis / rollinggcjp 上 2 API + 2 worker 四绿(a2b4ed8)

30 秒版#

一个 Agent 要上公网,外面包了四层:bcrypt+JWT 的账户、按美元成本记的每日 credit 配额、Redis Stream 队列削峰、跨进程的事件背板与控制面。2026-09-19 的阶段 1 把它从「单进程能跑」改成「只能多副本跑」:额度改成先预扣后结算的 run_holds 表、同 thread 唯一性改成 threads 表的一条条件 UPDATE、去重窗口搬进 Redis,起服时用形态闸拒绝 SQLite。

3 分钟版#

  1. 为什么要闸。 一轮几十秒、按 token 花钱。开放注册后三种打穿法:一个人连发 N 条、脚本注册一堆号、长任务占满并发。
  2. 配额按 cost_usd 算不按 token 数(app/db/quota.py)。对外只显示整数 credit(CREDITS_PER_USD = 1000,日额度 DAILY_QUOTA_USD 2.0 美元 = 2000 credit),UTC 自然日靠新插一行重置,没有定时任务。
  3. 额度闸从事后记账挪到进门(1-1 9ddd290)。run_holds 一行一个 run:进门 acquire_hold 锁用户行、数在飞数、扣额度;跑完 settle 用条件更新做幂等,重投不双记。
  4. API 只入队,worker 才跑 AgentLoop(1-7 0978dc1)。QUEUE_ENABLED / CONTROL_ENABLED / BACKPLANE_ENABLED 三个开关删掉恒开,进程内队列只留给单测注入。
  5. 关停不再静默重跑(1-3 c15ac5b)。排空超时的任务当场给定论:发 task_interrupted → 还占位 → 写 interrupted → ack,不再留 PEL 等十分钟后没人看的重跑。
  6. 多副本验收台(1-5 a2b4ed8):docker/docker-compose.multi.yml 起 2 API + 2 worker + MySQL 8 + Redis,用 scripts/stub_agent.py 替掉真 run_agent,四个场景全绿。
  7. 切 MySQL(1-6 f9efe2b):搬数脚本 + 首启迁移串行锁;再补参数覆盖的每进程对账(1-8 83378ac),因为改配置的是 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_SEC 900s)过期,不加扫表协程。

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 分开返回。去重窗口改 Redis SET 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_SECONDS 120 → 330(旧默认比单轮超时 MAIN_AGENT_TIMEOUT_SEC 300 还小);排空超时的当场收尾并 ack。TaskState 加 interrupted + TERMINAL_STATES 单点定义,前端显示同名状态并提示「重发一次」,不自动重发(多副本同时中断会在同一秒打出一波重试)。
  • 代价:用户要手动重发一次;收尾整体限时 WORKER_INTERRUPT_FINALIZE_SEC 5s,超时照样 ack(走到那里说明 DB/Redis 已经不好使)。

4. 两个进程各算各的

  • 坏法一:产物。output/ 与 uploaded/ 各按项目根算,worker 写、API 读,不在同一文件系统位置时读侧一律 404 且不报错,表现为「产物莫名其妙没了」。
  • 修法一(1-4 e9022bb):统一从 ARTIFACT_ROOT 派生(默认项目根,本地行为不变),path_utils 自己 load_dotenv 一次;prod compose 补 worker 服务,三个卷与 backend 逐字相同,stop_grace_period 360s。
  • 坏法二:配置。后台改参数写的是 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,顺序即代码顺序):

  1. 身份:AUTH_ENABLED 时只取 JWT 的 sub,忽略请求里的 user_id(resolve_identity)。
  2. 配额 _enforce_quota(server.py:350):今日额度用尽 → 402,不占 thread、不占槽、不入队。
  3. 归属 _claim_thread_if_needed(server.py:707):claim_thread 自带属主校验,拿别人的 tid 发消息 → 403。
  4. 分档 classify_request(turn_count):按该 thread 历史轮数分 normal / heavy,是预扣 credits 与分流到哪条 Stream 的共同依据(app/api/concurrency.py 现在只剩分档)。
  5. 深度背压 _queue_depth_or_429(server.py:714):depth() >= QUEUE_MAX_DEPTH(200,server.py:128)→ 429 + Retry-After。
  6. 预扣 _acquire_hold_or_reject(server.py:722):并发超限 429 / 额度耗尽 402。排在幂等判定之前,因为下面那整段必须一个 await 都没有。
  7. 幂等第 1 层 claim_thread_run(app/db/runs.py):同 query → already_running(还掉预扣、领回原任务);换 query → replaced,带回旧 run_id,取消按它送(旧 run 可能在另一台副本)。
  8. 幂等第 3 层 dedup.check_duplicate:只对不自带 thread_id 的客户端(脚本 / 裸 API)生效。前端是 connect-first、tid 存 localStorage,并进别的 thread 会让它那条 WS 一个字都收不到。Redis 不可用 → 503,不静默吞。
  9. 入队:XADD 到 globex:intents 或 :large,立即返回 thread_id;API 只登记一个等结果的影子协程。
  10. worker 侧(app/worker.py):消费者组领消息 → handle_task → run_agent → 成功才 ack;并发由信号量控在 WORKER_CONCURRENCY(4,worker.py:58)。
  11. 没人领(4-1 307d12d):queued 超 QUEUE_START_TIMEOUT_SEC(60s,server.py:141)判定无 worker 在领 → 发 queue_timeout、先落取消标记再复查状态、还预扣/占位/指纹、终态写 failed(可重发,不是 cancelled)。没有控制面时不作废——标记落不下去就拦不住 worker,此时报超时是骗用户。
  12. 关停(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-198b3b2d4 / 3b3790ek6 脚本跑 connect-first 全链路,替掉 Python 压测客户端500 并发实测 5~6s 延迟全来自 Python 客户端与 macOS backlog,不是服务端真延迟
09-199ddd290(1-1)run_holds 预授权表 + 按 run_id 幂等结算 + 用户级并发上限事后记账拦不住并发透支,重投会双记
09-19b79efb7(1-2)同 thread 唯一真相进 threads 条件更新;dedup 窗口进 Redis两者原先都在 API 进程内存,多副本下给不出答案
09-19c15ac5b / 581979e(1-3)grace 120→330;掐断按 interrupted 收尾并 ack;k8s terminationGracePeriodSeconds 150→365旧 grace 比单轮超时还小;重跑发生在十分钟后没人看,钱却真花
09-20e9022bb(1-4)产物根统一 ARTIFACT_ROOT;prod compose 补 worker 服务worker 写、API 读,不同路径时读侧静默 404
09-20a2b4ed8(1-5)docker-compose.multi.yml + scripts/stub_agent.py + 四场景,在 gcjp 四绿1-1~1-4 全是多副本正确性,单进程测不出来
09-20f9efe2b(1-6)SQLite→MySQL 搬数脚本 + 首启迁移串行锁(GET_LOCK / advisory lock)四容器同时 upgrade head,MySQL DDL 非事务,撞 1050 留半成品表
09-200978dc1(1-7)删进程内双池准入与直跑分支;三个开关恒开;加 app/deployment.py 形态闸API 不跑 loop,那个并发数不对应任何真实资源;误配全是静默的
09-202ad5212prod 两个 build 段补 UV_EXTRAS=db起服闸要 MySQL,:latest 镜像里却没有 aiomysql;验收台用 :multi,照不出这个洞
09-2083378ac(1-8)参数覆盖改成每进程与库对账(30s 轮询)拆成两个进程后,管理员改的是 API,推理在 worker
09-21307d12d(4-1)入队等待上限 60s,超时连消息一起作废老行为干等 30 分钟才报超时,消息仍被重投静默跑一遍
09-2155bf774docker-compose.loadtest.yml 叠加层,只给 mysql / redis 开宿主高位端口multi.yml 本身不开对外端口(场景脚本靠 docker exec 发请求)
09-2183f6678(减法 C1)server.py 1779→1106 行,文件 / 偏好 / 订单拆成三个 router,守卫进 guards.py三摊与任务主线无关的事挤在主文件里;路由数拆前拆后都是 33,路径不变

数字与证据#

数字指什么来源状态
MAX_CONCURRENT_RUNS 3一个用户同时能有几个 run 在飞app/db/holds.py:51✓ 代码
QUEUE_MAX_DEPTH 200队列积压上限,超了 429app/api/server.py:128✓ 代码
WORKER_CONCURRENCY 4单个 worker 同时跑几轮app/worker.py:58✓ 代码
WORKER_GRACE_SECONDS 330 = 300 + 30排空窗口,须 ≥ MAIN_AGENT_TIMEOUT_SEC 300app/worker.py:62、app/deployment.py✓ 代码
k8s terminationGracePeriodSeconds 365= preStop 5 + grace 330 + 收尾余量 30deploy/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 900kill -9 后预扣与 thread 占位多久自然失效app/db/holds.py:55、app/db/runs.py:53✓ 代码
DAILY_QUOTA_USD 2.0 / CREDITS_PER_USD 1000每人每天 2 美元 = 2000 creditapp/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.pyworker 建 Future 时往 Redis 写等待令牌,API 落空再读令牌、经控制面广播 {reply_id, text},worker 比对 reply_id 才 resolve令牌过期按取消处理,迟到的回复拒收
取消指令找不到在哪个 workerapp/api/control.pyPub/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 题#

  1. 问:为什么预扣要排在幂等判定前面,不是更浪费吗? 因为幂等判定到入队那一整段必须无 await(单线程事件循环里没有 await 就不会被别的请求插进来),而预扣要查库。代价是幂等命中的请求先占后还,三条返回路各自 release。证据:app/api/server.py create_task 注释、9ddd290 正文。
  2. ⚠ 问:同 thread 双击是怎么挡住的? 不是靠字典,是靠 threads 表一条条件 UPDATE——判定与占位在同一条语句里,两台副本只有一条影响行数为 1。active_tasks 降级为本进程缓存(只服务 /inflight、取消口、影子协程身份)。证据:app/db/runs.py、b79efb7 正文。
  3. ⚠ 问:被掐断的任务为什么不重投? 重投发生在十分钟后,用户页面早关了,事件推给没人听的 thread,而整轮重跑的模型调用是真的再花一次——settle 的幂等只挡同一笔结算跑两遍,挡不住同一条 query 真跑两遍。证据:c15ac5b 正文。
  4. 问:interrupted 收尾顺序为什么不能换? 占位必须在写终态之前还,否则用户看到终态立刻重发会撞 already_running,被自己刚被掐的那轮挡住。有一条测试专门断言这个顺序。证据:c15ac5b 正文。
  5. 问:去重窗口 Redis 挂了为什么不降级回进程内? 降级的方向按「丢了什么」定:事件丢了只是少看几条,任务重跑是真花钱,所以去重直接 503。同理 enqueue 失败抛错,只有 depth() 这种纯观测吞异常。证据:app/api/dedup.py、b79efb7 正文。
  6. 问:配置热更新为什么用 30s 轮询而不用控制面广播? 配置是状态不是事件。广播是 fire-and-forget,worker 重连期间错过一条就永远是旧值;轮询每轮重新对账,丢了下一轮补回来。取消指令必须实时才走广播。证据:83378ac 正文。
  7. 问:迁移锁为什么用 GET_LOCK 而不是在表里插一行? 命名锁是会话级的,连接一断两种库都自动释放;锁表插行的话进程被 SIGKILL 就留下一把永远解不开的锁。还有一处细节:探库必须挪进锁里面,锁外探到的表清单一拿到锁就过期,legacy 判断会判反。证据:f9efe2b 正文、app/db/session.py。
  8. ⚠ 问:起服闸为什么放 app/deployment.py 而不是 app/api/? 两个进程入口共用一份,各写一份迟早漂成「API 起得来、worker 起不来」;放顶层是为了 worker 不必为一道校验把整个 FastAPI 模块(连同 agent / tools 链)拖进来。证据:app/deployment.py 模块 docstring。
  9. 问:多副本验收为什么要用桩? 真 run_agent 一轮几十秒且花钱,验的又是编排正确性不是模型质量。桩不进主链路代码——没有 AGENT_STUB_SLEEP 这类 env 开关,生产误开就是全站假回答且不报错;容器把 command 指到 scripts/stub_agent.py 即可。证据:a2b4ed8 正文。
  10. ⚠ 问:四场景全绿,为什么还漏了 prod 镜像没装驱动? 验收台用 :multi 镜像(传了 UV_EXTRAS=db),prod 用 :latest(没传),两条构建路径分叉,要到新形态上线第一秒才暴露。另一条同源的:关着鉴权跑验收时 holds 全 noop、run_holds 是空表,1-1 那几刀一条都验不到。证据:2ad5212、a2b4ed8 正文。

坑与易混点#

  1. CLAUDE.md 第 7 节仍写「长期记忆 Store 随 DATABASE_URL 走 SQLite」,与 1-7 起「SQLite 只留作单元测试 fixture」的形态闸冲突(未改代码,只记在这)。
  2. scripts/loadtest_stub_server.py docstring 里「一条命令起单进程」已跑不通:形态闸要 MySQL 且无开关,队列模式下还必须另起 worker 进程。
  3. 压测数吞吐对应的是 WORKER_CONCURRENCY=64(线上默认 4),且 AUTH_ENABLED=false 时 holds 全 noop——这组数不能当线上容量说。
  4. MCP_SEARCH_* 环境变量名是历史遗留,单环之后没有 search 角色,只是 MCP 只读白名单的配置前缀。
  5. 覆盖重发按 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 章。