上周为了搞清楚一件事,我把整个周末搭进去了:新版 6.1-sol 到底比 6-sol 贵多少,贵出来的那部分值不值。这个问题在群里被问了不下十次,但大多数回答都停在单价对比——输入贵 40%、输出贵 40%,算完就结束了。我总觉得这种算法不太对,因为真正掏钱的时候,账单上写的不是单价,是「单价 × token 数 × 你跑了几遍」。所以我干脆搭了一套自己的题库,30 道后端编程题,两版各跑三轮,把编译、单测、并发压测、事件顺序全记下来,一笔一笔对账。
先把结论摆在前面:单价确实涨了 40%,但 30 道题跑完,实际账单只贵了不到 30%,因为新版返工的次数少。而这个「少返工」的优势,几乎全部集中在一类题上——带并发状态机的 TypeScript 任务。剩下那些接口设计、参数校验、SQL 索引题,两版拉开的差距小到可以忽略。所以这篇东西不是要告诉你「新版更强」,而是想把这道分水岭画清楚:什么题值得为它多付钱,什么题纯属白花。
如果你也在给团队的代码助手做选型,或者自己天天写后端、跟 async/await 和状态机打交道,下面这套测评口径、判分脚本和提示词骨架应该能直接拿去用。
1. 测评设计:30 道后端题是怎么挑出来的
1.1 为什么我坚持同一套题、同一份判分脚本、跑三轮
模型对比最容易翻车的地方是变量太多。同一个提示词,温度、上下文长度、系统设定、判分标准任何一项变了,结论就没法复用。我第一版测评就是随手出题、肉眼看代码,结果两版各跑十题,我自己都说不清新版到底强在哪——A 题新版好,是因为我给它的描述更详细;B 题旧版好,是因为它偷懒少写了校验,代码看起来反而更简洁。样本量这么小,得出的结论纯属自欺欺人。
所以这次我把变量锁死:题库固定 30 道,系统设定固定成一段 40 字的角色描述,温度统一,每次开新会话、清空上下文,不允许追问、不允许看第二轮回答。每版跑三轮,三轮取中位数而不是平均值——只要有一轮出现超长输出或者卡死重试,平均值就被拉歪了,中位数更抗噪。
三轮的另一个好处是能看稳定性。真实开发里你很少只用一次生成结果,往往是它写、你改、跑挂了再问。所以「同一题三次结果是否都能过编译、过单测」这个指标,比单次最好成绩更有参考价值。我这里说的稳定,是指结果的一致性,不是别的意思。
1.2 题库结构:四层难度与题型配比
| 题号区间 | 题型 | 数量 | 判分重点 |
|---|---|---|---|
| 1–8 | 接口设计与参数校验 | 8 | 边界值、错误码、类型收窄 |
| 9–14 | 数据库事务、索引与迁移 | 6 | 事务边界、死锁规避、索引命中 |
| 15–22 | 读日志排查与写修复 | 8 | 定位是否准确、修复是否最小化 |
| 23–30 | 带并发状态机的 TypeScript 任务 | 8 | 状态跃迁、资源清理、事件顺序 |
这个配比不是拍脑袋定的。后端日常里这四类基本对半开,而「并发状态机」占比看似偏高(8/30 超过四分之一),是因为这块最容易暴露真实水平——它要求同一份代码里同时管住类型、异步时序、资源清理三件事,任何一处含糊都会在压测里露馅。
顺带说一句,我没有放算法题。不是说算法不重要,而是它在后端日常工作里的出现频率远低于「把一个异步任务的生命周期管明白」,而且算法题的最优解太标准,模型背过的概率高,区分度反而低。
1.3 判分口径:四条线,缺一条结论就不可信
我给每道题都准备了四条判分线,任何一条挂了就算这题没过:
- 编译线:
tsc --noEmit零报错,go vet零告警。别小看这条,有些生成结果在strict: true下直接一屏红。 - 单测线:每题预先写好 8–14 条断言,全绿才算过。断言里至少两条是「反直觉边界」,比如并发上限设成 0、或者
maxRetries设成 -1。 - 压测线:1000 个任务、并发上限 8,跑 20 秒,看有没有重复执行、状态错乱、句柄泄漏。这一条是并发题的核心。
- 人工复核线:只看三件事——有没有偷偷改测试、有没有用
try/catch把错误吞掉、有没有硬编码返回值。
人工复核这条线最容易被省掉,但它是防作弊的关键。我见过生成结果为了让断言通过,直接在你的断言旁边加@ts-ignore,或者把expect(x).toBe(3)改成expect(x).toBeDefined()。这种「过」毫无意义,甚至会带坏团队里新人对测试的认知。
注意:判分脚本本身要先自测。我一开始写的压测脚本自己就有个计数 bug,导致两版都被判成「重复执行」,白折腾了一个晚上。
2. 成本账:单价涨 40%,账单只涨不到 30%
2.1 计价公式与被忽略的分母
费用 = 输入 token × 输入单价 + 输出 token × 输出单价。看单价表,输入从 2.5 涨到 3.5(+40%),输出从 10 涨到 14(+40%),整齐得像商量好的。
但真正决定账单的是两个分母:一次生成有多长,以及你要生成几次。
后端代码的输出有个特点——写得多、读得少。一次生成动辄两三百行 TypeScript,里面大量是类型定义、边界判断、错误处理分支,这些全是实打实的输出 token。所以输出单价的涨幅会被放大,而不是被稀释。这一点跟写文案、写注释不一样,那类任务输出短,输入占比高,涨价的感受就没那么明显。
反过来,输入这边反而有可能省。因为答题前我把题面裁得很干净,只给接口签名和约束,不给完整项目结构,也没有塞一整个文件让模型自己去捞上下文。输入裁得越干净,单价上涨的绝对影响就越小。
2.2 重试与返工:真正的隐藏成本
我把「返工」定义为:编译不过、单测挂、压测挂,三者任一即计一次。这里有个关键点——返工的成本不是线性的。返工的时候,你得把错误日志、原代码、你的修改意见一起塞回上下文,这一轮的输入 token 往往是首轮的 2 到 3 倍。也就是说,一次返工的钱,差不多等于首轮的三倍。
所以一个返工率高 5 个百分点的模型,账单可能高出 30%。这也是为什么「单价便宜」在代码场景里经常是个假象。你省下的那点单价,一次返工就还回去了。
我这个测评里,6-sol 在 30 道题上累计返工 18 次,6.1-sol 是 10 次。差 8 次,按每次平均多花 1.2 万输入 token、2500 输出 token 粗算,光返工这一项就差出将近 1.4 美元。这笔钱已经接近两版基础费用的差额了。
2.3 30 道题的实际花费对账表
| 指标 | 6-sol | 6.1-sol | 差异 |
|---|---|---|---|
| 调用次数(含返工) | 108 | 97 | −11 |
| 输入 token | 1.35M | 1.28M | −5.2% |
| 输出 token | 0.72M | 0.66M | −8.3% |
| 输入单价(每百万) | 2.5 | 3.5 | +40% |
| 输出单价(每百万) | 10 | 14 | +40% |
| 输入费用 | 3.38 | 4.48 | +32.5% |
| 输出费用 | 7.20 | 9.24 | +28.3% |
| 合计 | 10.58 | 13.72 | +29.7% |
| 单题均价 | 0.353 | 0.457 | +29.5% |
算一遍你就明白了:3.38 + 7.20 = 10.58;4.48 + 9.24 = 13.72;13.72 ÷ 10.58 ≈ 1.297。单价涨 40%,实付涨 29.7%,中间那 10 个百分点是「少调用了 11 次、少输出了 6 万 token」换来的。
单题差 0.104 美元。这个数字放在个人身上无所谓,放到一个二十人的后端团队、每人每天问二十次,一个月就是四百多美元的差额。所以「贵了多少」这个问题,答案得看你的调用量,而不是看单价表。
还有一个更细的切法:如果把 8 道并发状态机题单独拿出来,它们吃掉了两版总费用的四成左右,也吃掉了差价的一大半。剩下的 22 道题,两版账单差距缩到 15% 以内。换句话说,你为新版多付的钱,几乎全花在那一类题上了。
3. 并发状态机这一类题:新版赢在哪,又勉强在哪
3.1 题目长什么样:TaskRunner 的六个硬约束
这 8 道题共用一个基底:实现一个带并发上限的异步任务执行器。约束写得比较死,为的就是把「糊弄」的空间堵上。
export type TaskState = | 'pending' | 'running' | 'succeeded' | 'failed' | 'retrying' | 'cancelled'; export interface TaskOptions<T> { id: string; run: (signal: AbortSignal) => Promise<T>; maxRetries?: number; } export interface RunnerEvent { id: string; from: TaskState; to: TaskState; attempt: number; at: number; }六条硬约束:
- 并发上限可配,默认 8,设成 0 时所有任务停在
pending。 - 失败自动重试,最多 K 次,退避按指数增长,基数 100ms,上限 5s。
- 支持
pause()与resume()。暂停后不启动新任务,已运行的不打断。 - 支持
cancel(id)和cancelAll(),必须能真正中断正在执行的异步函数。 - 每次状态变更都要发事件,事件里的
from必须是变更前的真实状态。 - 并发上限为 1、且不重试时,事件序列必须严格可断言。
第 3、4、6 条是分水岭。前两条考的是「会不会写队列」,后三条考的是「懂不懂异步时序」。
3.2 旧版翻车现场:两处竞态和一个幽灵定时器
旧版在这道题上的典型写法是这样的:
private drain() { while (!this.paused && this.running < this.limit && this.queue.length) { const task = this.queue.shift()!; this.runOne(task); // 没有 await } } private async runOne(task: any) { this.running++; task.state = 'running'; this.emit(task); try { await task.run(new AbortController().signal); task.state = 'succeeded'; } catch (e) { if (task.attempts++ < this.maxRetries) { task.state = 'retrying'; this.queue.push(task); setTimeout(() => this.drain(), backoff(task.attempts)); } else { task.state = 'failed'; } } finally { this.running--; this.emit(task); } }问题一:重试时把同一个对象塞回队列,而running--在finally里执行、drain()又由定时器单独触发。当pause()之后再resume(),resume里也会调一次drain,两个入口同时进 while 循环,队列里那条被塞回去的对象就会被消费两次。压测里的表现是:1000 个任务,实际执行了 2000 次。这个 bug 单测抓不到,只有并发跑才现形。
问题二:setTimeout的句柄没有任何地方持有。cancelAll()之后定时器照样到点触发drain,把一条已经标记为cancelled的任务重新捞起来跑,事件流里出现cancelled → running这种非法跃迁。
问题三,也是最隐蔽的:new AbortController().signal是个临时对象,cancel(id)时根本拿不到这个 controller。所以「取消」是个假实现——run里那个函数压根收不到中断信号。它之所以能混过一部分测试,是因为没人真的去调cancel然后断言 Promise 被 reject。
这三处问题在新版里被修掉了两处半。为什么是半处,往下看。
3.3 新版的改动与它留下的尾巴
新版做了四件事,方向都对:
- 每次重试创建新的任务记录对象,不再复用同一条,避免「同一条被塞两次」。
- 退避定时器统一存进
Map<string, ReturnType<typeof setTimeout>>,cancel时clearTimeout。 AbortController按 id 存进 Map,cancel时真的去 abort。resume()里加了if (this.draining) return;的守卫,防止重入。
但留下两个尾巴,这两个尾巴很能说明问题。
尾巴一:事件发出与状态赋值的顺序反了。
private transition(task: any, to: TaskState) { this.emit({ id: task.id, from: task.state, to, attempt: task.attempt }); task.state = to; // 后改 }监听器里拿到的from是对的(因为那时还没改),但如果监听器里顺手查一下当前的运行状态,读到的还是旧值。并发为 1 时事件序列断言能过,因为你断的是事件本身;可一旦你在监听器里做「非法跃迁检查」,就会误报。这个 bug 单测抓不到,因为它只在「监听器读状态」这条路径上暴露。
尾巴二:超时计时器没清。
await Promise.race([task.run(signal), timeout(30_000)]);timeout里的setTimeout在run先完成时没有clearTimeout。Node 下表现为测试跑完之后进程不退出,要干等三十秒,加--detectOpenHandles会直接告警。功能是对的,资源清理没过关——这也是我判它「勉强」最直接的原因。
3.4 为什么说「赢得很勉强」:数字比感觉可靠
| 指标 | 6-sol | 6.1-sol |
|---|---|---|
| 首轮一次过(第 1 轮) | 3/8 | 5/8 |
| 首轮一次过(第 2 轮) | 4/8 | 6/8 |
| 首轮一次过(第 3 轮) | 3/8 | 5/8 |
| 三轮中位数 | 3.5/8 | 5.5/8 |
| 压测无重复执行 | 2/8 | 6/8 |
| 压测无句柄泄漏 | 1/8 | 3/8 |
| 平均返工次数 | 2.1 | 1.2 |
看这张表,新版在「逻辑正确性」上确实领先,5.5 对 3.5,接近一倍的差距,这是实打实的。但在「工程完整性」上,双方都不过关——句柄泄漏这一项,3/8 对 1/8,新版也只过了不到一半。
再把「一次过」的题拆开看,更有意思。新版那 5 道里,有 2 道是因为它主动写了try/finally清理,属于「刚好撞上」;旧版那 3 道里,有 2 道是题目恰好落在退化情况下(并发上限为 1),队列里永远只有一条,竞态根本没机会发生。也就是说,题目难度一上去,两版的差距会被放大;题目难度一降,差距立刻缩没。
这就是「赢了但很勉强」的量化含义:它在核心难点上确实更强,但没有强到「你可以不看代码直接用」的程度。你还是得逐行读它的资源清理。
3.5 我压箱底的「状态机五问」提示词骨架
测到第三轮的时候我意识到一件事:换模型带来的提升,还不如改提示词带来的提升大。我现在固定在这类题前面加一段骨架,让模型先回答五个问题,写在注释里,再动笔写实现。
你要实现的是一个异步任务执行器。在写代码之前, 先用注释回答下面五个问题,逐条写清楚,再开始写实现。 1. 状态合法跃迁表是什么?把非法跃迁全部列出来,运行时在哪里拦截? 2. 哪些可变状态会被并发访问?在单线程事件循环下, "保护"具体指什么(提示:await 前后会不会被重入)? 3. 每一处 setTimeout、AbortController、Promise.race, 清理点在哪里?谁负责清理?没清理会发生什么? 4. 状态赋值和事件发出,哪个在前?监听器在回调里读状态,读到的是新值还是旧值? 5. 假设 limit = 1、不重试、三个任务两成一败, 完整事件序列是什么?先写出期望序列,再写实现。为什么这五问有效?因为它把「写代码」变成了「先写约束」。模型在回答第 3 问的时候,经常自己就写出「在 finally 里统一清理」,这时候你再让它写实现,句柄泄漏的概率会明显下降。
实测数据:加上这段骨架之后,6.1-sol 的「无句柄泄漏」从 3/8 提到 6/8,6-sol 从 1/8 提到 3/8。注意旧版提升的绝对值不小,说明这类问题很大程度上是「没被提醒」,而不是「能力不够」。第 5 问尤其管用,让它先写期望的事件序列,等于给了它一个自检基准,非法跃迁当场就会暴露。
4. 分项结果:谁更划算要看题型
4.1 接口设计与参数校验:省钱的战场
8 道接口题,考的是给定业务描述,产出请求体类型、校验函数、错误码映射。判分看三点:边界值有没有漏(空字符串、超长、负数、时区)、错误码是不是统一、类型收窄写得干不干净。
结果:6-sol 7.5/8,6.1-sol 7.8/8。差距 0.3 道,基本在噪声范围内。两版都能把zod或手写守卫写对,偶尔漏掉的是「同一字段在创建和更新时校验规则不同」这类业务细节,跟模型版本关系不大,跟你题面写没写清楚关系更大。
这一类题我现在的做法是:直接用便宜的那版,省下来的钱拿去补贴并发题。因为这类代码的特点是「错了立刻能看出来」——类型不对编译就红,边界漏了单测就挂,反馈极快,不需要一个更强的模型来兜底。
4.2 数据库事务与索引:差距存在但不关键
6 道题,包括「转账场景下的事务边界」「批量更新怎么避免锁等待」「慢查询加索引并解释为什么命中」。判分看事务隔离级别选得对不对、有没有把非事务操作混进事务里、索引列顺序是否匹配查询条件的最左前缀。
结果:6-sol 5.2/6,6.1-sol 5.6/6。两版都会写的:基本的BEGIN/COMMIT包裹、单列索引。两版都容易错的:把「先查余额再扣减」放在事务外、给低选择性的列建索引还振振有词。
这里有个细节值得说:新版在解释「为什么这个索引能命中」时,会更倾向于把执行计划的思路讲出来,而不是直接给一条CREATE INDEX。这个差别在你自己 review 的时候有价值,但如果只是要一条能跑的 SQL,多付的钱不太值。
4.3 读日志排查类:新版真正的第二个优势区
8 道题,给一段脱敏的报错栈加几条关键日志,要求定位根因并给出最小修复。判分看根因对不对、修复是不是最小改动、有没有顺手重构(顺手重构算扣分项)。
结果:6-sol 6.1/8,6.1-sol 7.3/8。差距 1.2 道,比接口题明显得多。
差别在哪儿?新版更愿意「先复述日志再下结论」。它会先把时间线捋一遍:哪条日志先出现、哪个耗时异常、哪个连接数在涨,然后再给结论。旧版经常跳步,看到ECONNRESET就直接说「网络抖动,加重试」,而实际上日志里明明有一条「连接池等待超时」。
这类题的价值在于「少走弯路」。一个错误的根因判断,可能让你在错误的方向上改两天。所以这 1.2 道的差距,换算成工时之后,比那点差价贵得多。
4.4 分项成绩与性价比对照表
| 题型 | 6-sol | 6.1-sol | 差距 | 建议 |
|---|---|---|---|---|
| 接口设计与校验 | 7.5/8 | 7.8/8 | +0.3 | 用便宜的 |
| 事务与索引 | 5.2/6 | 5.6/6 | +0.4 | 用便宜的 |
| 日志排查 | 6.1/8 | 7.3/8 | +1.2 | 用新的 |
| 并发状态机 | 3.5/8 | 5.5/8 | +2.0 | 用新的,但必须逐行审 |
这张表就是「贵了多少、值不值」的完整答案:总体贵 29.7%,但其中大约七成的差价花在了后两类题上,而这两类恰好是「错了代价最大」的两类。前两类题你完全可以用旧版,把预算挪过去。
5. 常见问题与排查实录
5.1 同一个提示词两次结果差很多
这是测评里最烦人的问题。我统计了一下,原因大概归成四类:上下文没清干净(上一轮的代码还在会话里,模型会去「接着改」)、温度没固定、系统设定被后续对话覆盖、题面里有「上面的代码」这种指代。
对策很土但有效:每题新开会话;把错误日志裁成「栈顶 30 行 + 关键断言 + 报错那一行的上下文 5 行」,不要整段贴;题面里不用「上面」「刚才」这类词,全部写成自包含的。
裁日志这一条我一开始很抗拒,觉得信息给全一点模型才判断得准。实测下来正相反——贴一整段 500 行日志,模型反而会去抓一些无关的噪音行,结论更飘。给 30 行精准的,命中率明显更高,而且还省钱。
5.2 代码「看着对、跑起来挂」
并发题上这句话出现的频率最高。我把踩过的坑归成三类,做个自查清单:
- 假取消:
AbortController建了但没传进去,或者传进去了但run里没监听signal。自查方法:在run里加一行signal.addEventListener('abort', () => console.log('aborted')),看取消时有没有打印。 - 幽灵定时器:
setTimeout没存句柄。自查方法:跑完测试加--detectOpenHandles,或者干脆在测试末尾断言process._getActiveHandles().length回到基线。 - 事件顺序错:先发事件后改状态,或者反过来。自查方法:写一条「并发为 1、三任务两成一败」的用例,把事件序列打出来逐字比对,别用
toContain,用toEqual。
提醒一句:这三类问题有一个共同特征——静态读代码读不出来。你必须跑。所以我现在的流程是「生成完先跑压测,跑完再读代码」,而不是反过来。
5.3 账单突然翻倍的四个原因
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 输入 token 暴涨 | 返工时把整个文件贴回去 | 只贴函数体 + 报错行上下文 |
| 输出 token 暴涨 | 让模型「再解释一遍思路」 | 解释另起一轮,别混在生成里 |
| 每轮都涨一点 | 长会话不清理,历史全带上 | 超过 6 轮就新开会话,把结论摘出来带走 |
| 调用次数暴涨 | 压测脚本自己死循环触发重试 | 先给脚本加最大重试次数上限 |
第二条我踩得最狠。有一次我让它在输出代码后再「简要说明设计取舍」,结果它写了 800 字的设计文档,输出 token 直接翻倍,而那段说明我根本没看。后来我把「解释」和「生成」彻底拆成两轮,只在需要 review 的时候才要解释,账立刻降下来。
5.4 问题速查表
| 现象 | 排查顺序 |
|---|---|
| 压测执行次数翻倍 | 队列是否塞了同一对象 → drain 是否有两个入口 → 是否有 await 期间的重入 |
| 事件流出现非法跃迁 | 先查定时器有没有清 → 再查 transition 里赋值与发出的顺序 |
| 测试跑完不退出 | --detectOpenHandles→ 找Promise.race的超时分支 → 找未清的setInterval |
| 取消不生效 | signal 有没有传进run→run内部有没有监听 → abort 后 Promise 有没有 reject |
| 单测过了但线上挂 | 单测是不是只测了 limit=1 → 补一组 limit=8 的并发用例 |
6. 落地建议:怎么按题型分流
6.1 三道门槛决定要不要上新版
我不建议无脑上新版,也不建议死守旧版。用三道门槛过一遍:
第一道,你的代码里有没有「顺序 + 并发 + 清理」三件事同时出现。有,就上新版;只是普通的增删改查、拼装响应体,旧版足够。这一条能挡掉大概七成的场景。
第二道,一次返工的时间成本,是否高于那点差价。单题差 0.1 美元,折成人民币不到一块。而你调试一个竞态 bug 花两小时,成本是它的几十倍。所以只要这道题「错了很难查」,就值得多付。
第三道,你手上有没有判分脚本。这一条最容易被忽略。如果你没有单测、没有压测,只能靠肉眼看代码,那新版给你的那点正确性提升,你根本验证不了,钱就白花了。判分脚本是这一切的前提。
6.2 混用路由的简单实现
我自己写了个二十行的路由脚本,按题型打标签,自动选版本。不复杂,但省心。
const ROUTE = { 'api-design': 'model-6-sol', 'param-validate': 'model-6-sol', 'db-migration': 'model-6-sol', 'sql-index': 'model-6-sol', 'log-diagnosis': 'model-6.1-sol', 'concurrency-fsm': 'model-6.1-sol', 'async-cleanup': 'model-6.1-sol', }; function pickModel(tag) { return ROUTE[tag] ?? 'model-6-sol'; // 默认走便宜的 }关键在最后那行默认值。我的原则是「默认便宜,例外才贵」,而不是反过来。因为一旦默认走贵的,你会发现所有请求都悄悄走了贵的那条路,一个月后账单一来才反应过来。
配套还有个「降级规则」:同一道题如果新版连挂两次,第三次自动切回旧版重跑。听起来反直觉,但实测有效——新版在个别题上会陷入一种「越想越复杂」的状态,生成的代码一层套一层,反而不如旧版直给。这时候换个模型,等于换条思路。
6.3 一点个人体会
测完这 30 道题,我最大的收获不是「哪个版本更好」,而是把「好在哪」拆成了可以量化的东西:并发正确性、资源清理、事件顺序、返工次数。这四项指标一摆出来,选型就不再是靠感觉拍板了。
另外还有一个挺反直觉的发现:提示词骨架的收益,稳定地大于换模型。同一道并发题,给旧版加上那五问,通过率从 1/8 提到 3/8;不加五问直接换新版,从 1/8 提到 3/8——一模一样。也就是说,如果你现在只打算做一件事,先把那五问加进你的模板里,比升级版本划算得多。
最后留个可扩展的方向:我这套题库目前只覆盖 TypeScript 和 SQL,下一步想补上「分布式任务重试」「消息幂等消费」这两类,因为它们跟并发状态机是同源的,但又多了跨进程这一层。等攒够样本,再回来对一次账。