流萤简历是我做的 AI 简历与求职资料工作台。这是专栏第 6 篇,从润色与一键生成切入,聊次数预占、幂等、结果校验和故障恢复。
产品特写:建议先看,再决定是否写进简历
流萤的语句润色把原文与建议放在一起,用户可以补充要求,生成后再决定是否应用。它适合处理"经历是真的,但表达还没组织好"的场景。另一边,一键生成从真实经历、目标职业和可选 JD 出发,整理出新的可编辑草稿;你也可以带入已有简历,再精简材料,保留原件。

截图中的建议区还没有生成结果,底部"应用建议"处于禁用状态。这说明生成和应用是两个产品步骤:服务端先交付可回看的结果,用户再选择是否进入自己的简历。点击取消,不应该被误解成模型从未执行;模型失败,也不应该留下一次无解释的消耗。
产品里写"失败不扣次"很短。要兑现它,需要先定义什么算开始、什么算成功、请求重试如何识别、进程崩溃后如何释放占用,以及迟到的结果是否还能结算。
余额检查以后,还有一段并发窗口
假设用户只剩 1 次,几乎同时发起两个 AI 请求。若代码先查余额,得到 1,再调用模型,两个请求都可能通过检查。等它们成功以后再扣,账本可能超用;先扣再调用,失败恢复又会变成另一套补偿流程。
当前实现把次数分成已用 used 和预占 reserved。请求开始时在短事务里预占,成功时把预占转为已用,失败时释放预占。预占表示这份额度已经被正在执行的操作占住,但还没有成为成功消费。
账本里还包括退款冻结和已撤回等字段。可用量按每份 grant 的剩余额度计算,核心关系是:
text
可用次数 = quantity - used - reserved - refundFrozen - revoked
grant 是一次赠送、购买或其他来源形成的额度记录。代码对各份记录的可用量做非负处理再汇总,这个公式描述的是用户可用余额,不应当被误写成只要 quantity - used 就足够。

状态机里没有"失败后再成功"的普通转换。失败的请求键不会重新跑一次模型,而是返回 RETRY_NEW_KEY。这是为了让同一次操作的结局稳定,新的尝试使用新的身份。
beginOperation 把哪些责任放进一个事务
beginOperation() 先对功能与输入做稳定摘要,然后在数据库事务里获取用户锁、处理过期占用、查询已有请求,再检查功能和额度,最后预占并创建操作。
同一个账号的并发操作使用共同锁。前一个请求预占后,后一个请求读取到的余额已扣除该预占,不能同时抢到最后一次。这种约束放在数据库层,多进程部署才不会各自认为自己有一把有效的本地锁。
请求身份由账号与 requestKey 共同定位,输入摘要还包含功能和业务输入。同一个键配不同内容会冲突;相同键已经运行中返回 IN_PROGRESS;相同键已经成功直接返回已保存结果;相同键已失败要求新开任务。
| 已有操作状态 | 当前输入相同 | 服务端行为 |
|---|---|---|
| running | 是 | 返回处理中,不再调用模型 |
| succeeded | 是 | 返回已成功结果 |
| failed | 是 | 返回重开任务提示 |
| 任意已有状态 | 否 | 拒绝复用同一请求身份 |
为什么要比较输入摘要?用户第一次润色 A 段,第二次换成 B 段,却误用了同一个键。如果只按键返回缓存,会把 A 的结果当成 B 的结果;如果直接再次执行,又可能使同一操作身份对应两个副作用。
频控也在这里检查。当前最近请求、运行中数量与近一天请求数等限制基于操作记录,失败尝试也可能参与统计。这里的"失败不扣次"指成功消费账本,不代表所有失败尝试都不会占用频控额度。两类限制解决的问题不同。
模型请求不能一直占着数据库事务
大模型调用可能耗时几十秒。如果在预占事务里等模型返回,同一账号的锁会被长时间占住,数据库连接也会跟着外部网络请求等待。另一个页面读权益或保存相关资料,都可能被拖住。
当前链路在预占事务提交后调用模型,拿到结果并校验,再开启结算事务。外部请求发生在事务之外,长耗时与短临界区分开。

这种结构不可能把模型提供商与本地数据库变成一个分布式原子事务。模型可能已经计算完成,应用却在保存结果前退出。需要恢复的是本地的占用和操作状态,而不是宣称外部推理从未发生。
同样,提供商可能已经产生费用,但用户得到的结果不合格,产品选择不消耗用户次数。这是交付标准与成本承担的选择。不能从"用户没扣次"推断模型供应商也不会计费。
什么才算一份可以结算的结果
HTTP 200 只能说明提供商返回了一次成功响应。流萤还需要知道输出是否完整、能否成为产品里的对象。AI 接口在协议边界检查 finish_reason、内容类型、空文本和长度;达到输出上限会被识别为截断,进入失败路径。
简历生成进一步解析 JSON。当前默认流程把真实经历作为事实来源,JD 只影响表达重点,不能把岗位要求变成用户已经具备的技能。解析器保留支持的字段与文本,并允许应届生没有工作经历;只要教育、工作、项目或技能中存在有效内容,就不应该因为缺少工作栏而拒绝整份材料。
经历材料上限为 20,000 字,JD 上限为 10,000 字。只有 JD、没有经历时,输入边界会直接拒绝:招聘方要求熟悉分布式系统,不代表这个用户做过分布式项目。把这件事交给接口约束,而不只写进界面提示,网页和 CLI 才能遵守相同前提。

上图是官网的生成场景示意;工作台中的生成表单承担真实经历与 JD 输入,版式只决定内容如何呈现。
旧客户端只有 career 的请求仍保留职业占位示例,这是已发布协议的兼容分支;网页默认流程则要求真实经历,缺失个人字段留空。两种输入的幂等摘要也要保持自己的语义:只传职业沿用原摘要,提供材料时则把经历与 JD 一起纳入,材料变化后不能再拿旧键返回旧结果。
解析器的结构检查不能证明每句话都忠于原文。提示词要求不新增公司、职责、技术栈和业绩数字,但自由文本是否准确仍需要用户核实;这也是产品把结果保存为可编辑新草稿、保留原简历的原因。确定性校验负责结构与边界,用户继续掌握事实和定稿。
润色还有一条更贴近用户感知的规则:如果输出去掉空白后与原文一致,就不是可交付的新润色内容。service 抛出 AI_POLISH_UNCHANGED,失败路径释放预占。这让"一次成功"的定义与产品实际价值关联,而不只是"供应商给了一个字符串"。
结算前,业务内容可能已经变了
生成简历返回候选内容,通常不直接覆盖已有简历;资料生成则可能需要把解答写进资料库。后一种情况下,模型返回的时间里,用户可能已经修改原文。
finishOperation() 接受一个可选的 persist(tx) 回调,让相关业务写入与账本结算在同一事务中完成。资料生成可以在回调里检查原 revision 并通过统一写路径保存,只有保存成功才把预占转为已用。

这条箭头很有价值。用户原文已经从缓存问题改成数据库问题,旧答案即使内容完整,也不该无条件写到新材料下面。业务一致性失败,不能只因为模型完成了就扣次。
finishOperation() 的关键顺序如下,摘录省略了操作读取与锁,代码仍在同一个数据库事务回调里:
ts
if (persist) await persist(tx);
if (op.grantId)
await tx.update(grants).set({
reserved: sql`${grants.reserved} - 1`,
used: sql`${grants.used} + 1`,
}).where(eq(grants.id, op.grantId));
await tx.update(ops)
.set({ status: "succeeded", result, finishedAt: new Date() })
.where(eq(ops.id, id));
业务持久化先通过,账本与成功结果再一起提交。persist 是协议明确可选的回调,grantId 可空代表本次操作可能没有占用付费 grant,这两个判断对应真实数据语义。
如果先结算再写资料,会出现次数用了、结果没落地;如果先写资料再在另一个事务结算,会出现资料已经更新、账本还在 running。把同一数据库里的相关状态收在一个事务里,可以消除这两种半完成状态。
回调也不能随意拉进另一个慢网络请求。它的责任是本地可事务化的持久化和校验,长耗时仍应留在外部流程。短事务的价值来自整个调用链遵守边界。
进程崩溃后,预占怎样回来
正常异常能走 failOperation()。它只把仍是 running 的操作标记失败,并释放对应预占;已经结束的操作不会再次减预占。这是状态条件提供的幂等性,不能靠无条件减一再修负数。
进程直接退出则没有机会执行 catch。当前 releaseStale() 把创建超过 3 分钟、仍在 running 的操作标失败,并释放占用;模型请求超时设置为 90 秒,3 分钟留出处理与异常恢复余量。
这里要说清触发方式:这段回收在 catalog()、beginOperation() 等业务事务里执行,不是文章可以凭空宣称的独立后台定时器。若用户没有后续相关访问,状态不保证刚过 3 分钟就立刻变化;它在后续成功提交的恢复事务中被处理。
迟到的工作者还要被挡住。结算再次要求操作仍为 running,回收已经把它改 failed 后,晚返回的结果不能重新消费。否则系统刚把额度还给用户,旧请求又来扣一次,恢复机制自身就制造了第二个竞争。
这类状态条件可以理解为一道结算栅栏:是否允许完成,要由数据库当前状态决定,不能由工作者自己记得"我当时开始过"决定。
用户重试时,键要跟随操作身份
网络超时是一种不确定状态:操作可能没开始,也可能已经成功而响应丢失。相同输入、相同请求键的重试,可以让服务端返回原来的状态与成功结果。客户端如果每次点击重试都生成新键,就可能重复发起模型并消耗多次。
但不是所有错误都适合复用旧键继续等。已经明确失败且释放的操作需要新键;输入内容变化也需要新身份;版本冲突后形成了不同候选,不能假装还是同一个请求。
对 CLI 自动化而言,返回错误码和恢复语义尤其重要。模型不应该把"处理中"当成"再发一个请求",也不应该把"余额不足"当成"换一个键试试"。通用客户端负责传输,业务状态机负责允许发生哪些转换。
成功结果保存在操作记录里,还支持历史回看。回看结果不再次调用模型、不重复结算。用户可以先查看建议,再决定是否应用到简历;不应用也不代表此前成功生成的结果失去价值或自动退回次数。
买完次数,回到刚才的任务
计次系统还有一段容易被忽略的用户路径:写好经历和补充要求以后,才发现余额不足。如果购买必须离开当前页面,用户可能付完钱回来又要重填材料。收费链路即使账目正确,也会把原任务打断。
流萤把共享购买弹窗挂在当前任务之外,原生成表单和编辑器继续保留。弹窗只管理要购买的功能,查单确认 paid 后刷新余额,给出"继续刚才的任务"。它不在到账回调里自动执行生成,避免一次支付成功就带来用户没有再次确认的新消耗。

creditsRevision 在这里是本地余额失效通知,触发各功能重新读取服务端权益,不是客户端自己增加额度。切换账号会关闭购买弹窗,避免把旧账号的购买状态带到另一个任务里。材料保留依赖页面继续挂载,并不等同于把未保存输入永久写到本地存储。
怎样验证"失败不扣次"这一承诺
可以用可控模型适配器返回超时、非法 JSON、截断输出、缺字段内容和原文回传,逐个观察操作状态与 reserved、used。验证目标是失败后预占释放、已用不增加,成功时两者同时转换。
并发场景给用户只留一次余额,放行两个同时开始的请求,确认只有一个能预占。传输场景让第一次结果已提交但丢响应,再用同键请求,确认结果一致且没有第二次模型调用。
恢复场景让操作停在 running,推进到回收条件后触发恢复,再尝试迟到结算,应被拒绝。资料生成则在模型等待时改一次 revision,确认正文没有被旧结果覆盖,账本也没有先成功结算。
可以从流萤简历的编辑器打开润色,先看看原文、建议和应用之间的关系,再决定是否发起生成。用户信任"失败不扣次",依赖的是一个能解释每次额度去向的操作生命周期。下一篇讨论简历真正离开工作台的那一刻:PDF 怎样保住排版。