【免费下载链接】freehire
freehire — the open-source search engine for job seekers
导读
freehire 的每一次模型调用最终都会落到某个 LLM 网关的凭证上。要让"谁花了多少、花在哪个功能上"这个问题有答案,就必须让每个已登录用户的调用走他自己那把虚拟密钥,而不是全站共用的一把服务密钥。internal/ai/llmkey/AGENTS.md 正是这个子系统的工作手册:它讲清了凭证如何铸造、如何解析、如何退役,以及哪些设计取舍是刻意留下的陷阱。本文以该文档为主线,结合 llmkey.go、resolver.go、bind.go 与 scope_test.go 的源码实现,把这套机制拆成可操作、可验证的工程指南。
读完你能掌握:四个环境变量如何开关整个特性、Credential为什么必须是"秘密 + id"两段、模板密钥里的 provider 策略如何被一次性复制、并发首次调用如何竞争、以及为什么"归因失败"永远不许让用户请求失败。
一、这个包到底负责什么,不负责什么
llmkey包的职责边界在包注释里写得非常明确(internal/ai/llmkey/llmkey.go):它负责铸造、读取、退役网关用来识别某个账号的凭证,以及把凭证交给调用方的解析器。它本身不发起任何模型调用。
真正花钱的克隆体在internal/platform/llm的Client.As(也就是AsCaller);同一个包里的Bind(bind.go)是"按用户归属的调用"唯一解析入口。
这个包的历史位置也值得知道:它原本是internal/api/handler内部的userLLM,只在 Fiber handler 里可用。当cmd/auto-apply成为第二个拥有同样所有权结构的调用方(队列行携带的是某个具体候选人自己的申请,auto_apply_queue.user_id),它没有理由去 import 整个internal/api/handler,于是这段逻辑被提取成独立包。这是一次"按证据重定位",而不是架构洁癖。
一个可以直接对照的事实:cmd/server在 cmd/server/main.go 构造llmkey.New,cmd/auto-apply在 cmd/auto-apply/main.go 构造同样的东西并加上NewResolver。只有这两个二进制被允许 import 这个包。
二、四条"永远成立"的不变量
AGENTS.md的 "Always true" 一节是整个设计的地基,逐条对照源码如下。
2.1 凭证对用户不可见
没有任何页面、没有任何字段、没有任何设置项,也不会出现在任何 API 响应里。它在该账号第一次发起 AI 调用时才被惰性铸造(lazy mint)。
这解释了为什么/me/usage的处理器只读不铸:usageHandlers.usage调用的是h.keys.Stored(...),而不是For(internal/api/handler/me_usage.go、#L60-L80)。如果在一个"看看自己用了多少"的页面里铸造密钥,那么每个出于好奇点开页面的访客都会拿到一把密钥,"有多少账号有密钥"这个指标就会从"谁花过钱"退化成"谁看过页面"。
2.2 归因永远不能让调用失败
无法铸造的凭证、连不上的管理 API、读不出的数据库、被网关拒绝的密钥——全部回落到服务凭证。
Resolver.For的签名就是这个决策的物理形态:它返回""而不是 error(internal/ai/llmkey/resolver.go)。注释说得很直白:这样调用点写client.As(resolver.For(ctx, userID), tag)就能在所有情况下都正确,而不需要记得处理任何分支。归因只是记账,丢一条报告记录,比丢一个用户要的答案便宜得多。
对应到bind.go,Bind与Caller同样"不可能失败"(internal/ai/llmkey/bind.go)。
2.3 无人拥有的工作保留服务凭证
一条目录里的招聘岗位没有主人。把它的富化(enrichment)归因给"恰好触发了这次运行的人",就等于把一笔成本记在一个没产生它的人头上——而那正是这个包存在的意义所要避免的谎言。
这条规则被结构性强制,而不是靠自觉:scope_test.go用go/parser解析 import,遍历cmd、internal/ai/enrich、internal/ingest/telegram、internal/application/mailclassify、internal/ai/embed五个根目录,任何.go文件(测试文件除外)import 了internal/ai/llmkey就测试失败(internal/ai/llmkey/scope_test.go)。
豁免是显式的、具名的,不是通配模式:
// 只有两个拥有真实、具名所有者的二进制可以写这个名字 if strings.Contains(slash, "/cmd/server/") || strings.Contains(slash, "/cmd/auto-apply/") { return nil }测试里还专门加了一道防空转检查:先os.Stat五个根目录,任一不存在就t.Fatalf。注释记录了一个真实的教训——包被移动到更深的目录后,硬编码的../深度直接找不到cmd/,再往上一层就会遍历一棵空树然后"通过"。第三个后台二进制需要自己的一行豁免和自己的理由。
2.4 未配置是正常部署,不是降级部署
LLM_ADMIN_URL、LLM_ADMIN_USERNAME、LLM_ADMIN_PASSWORD、LLM_ADMIN_TEMPLATE_KEY中任意一个未设置,New就返回nil,什么都不铸造,所有调用照旧走LLM_API_KEY。
这同时也就是回滚方案。TestNewReportsUnconfiguredAsNil(internal/ai/llmkey/llmkey_test.go)对四个字段逐一剥离验证:
func New(cfg Config) *Client { if cfg.BaseURL == "" || cfg.AdminUsername == "" || cfg.AdminPassword == "" || cfg.TemplateKey == "" { return nil } cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/") return &Client{cfg: cfg, http: &http.Client{Timeout: requestTimeout}} }模板密钥之所以算作"配置的一部分",理由很硬:没有它,铸造会成功,但产出的凭证被所有 provider 拒绝——一个看起来像模型故障、实际是少了一个环境变量的线上事故。缺失比微妙地损坏要好。
三、端点不在你会猜的地方
管理面在/api下,推理面在/v1下。这个客户端发出的每一次调用都走这几个路由(internal/ai/llmkey/llmkey.go):
| 用途 | 方法 + 路径 | 认证方式 |
|---|---|---|
| 读取模板策略 | GET /api/governance/virtual-keys/{id} | HTTP Basic(管理员) |
| 铸造凭证 | POST /api/governance/virtual-keys | HTTP Basic(管理员) |
| 停用凭证 | PUT /api/governance/virtual-keys/{id} | HTTP Basic(管理员) |
| 删除凭证 | DELETE /api/governance/virtual-keys/{id} | HTTP Basic(管理员) |
| 读取用量 | GET /api/logs/stats?... | HTTP Basic(管理员) |
三点必须记住:
- 认证是HTTP Basic 的管理员身份,不是 bearer token;
LLM_ADMIN_URL独立配置,永不由LLM_BASE_URL派生。TestLoad_GatewayAdminIsNotDerivedFromTheInferenceURL(internal/platform/config/config_test.go)就是这条规则的守门人。派生会编码一个关于"运维是怎么写 URL 的"猜测,同时放弃了更好的部署姿态:管理 API 可以不在公网主机上暴露;- 路径写成一个常量
keysPath,因为三个动词里任何一个拼错都会返回 404,而Block与Delete都把 404 读作"已经做完了"。
仓库的变更记录显示这部分端点形状来自网关迁移:openspec/changes/move-llm-gateway-to-bifrost/proposal.md说明四个端点移到/api/governance/...与/api/logs/stats,认证从 bearer 改为 HTTP Basic,旧的LLM_ADMIN_KEY被LLM_ADMIN_USERNAME/LLM_ADMIN_PASSWORD/LLM_ADMIN_TEMPLATE_KEY取代。
四、一个凭证是两段值,而且两段都是承重的
type Credential struct { ID string Secret string }secret是 bearer token,是模型调用唯一需要的东西;id是不透明的,是 block 和 delete 唯一接受的东西——拿 secret 去问它们会得到 404。只存其中一个,会得到一把"能花钱但永远无法撤销"的凭证,而这恰好是账号注销必须做的事。
两段在同一条语句里被写入和清除(users.llm_key、users.llm_key_id)。看 SQL(internal/platform/db/queries/llm_keys.sql):
-- name: ClaimUserLLMKey :one UPDATE users SET llm_key = sqlc.arg(llm_key), llm_key_id = sqlc.arg(llm_key_id) WHERE id = sqlc.arg(id) AND llm_key IS NULL RETURNING coalesce(llm_key, '')::text AS llm_key, coalesce(llm_key_id, '')::text AS llm_key_id;ClearUserLLMKey同样是两列一起清空:
UPDATE users SET llm_key = NULL, llm_key_id = NULL WHERE id = sqlc.arg(id) AND llm_key = sqlc.arg(llm_key);注释解释了为什么守卫只放在llm_key上:这一列才决定"这个账号到底有没有凭证";两列都守卫会让 0119 之前的行(secret 有、id 为 null)被读成未认领并被覆盖,从而孤儿化一把还在花钱的密钥。
4.1 0119 之前铸造的行是真实状态,不是缺陷
migration 0067_user_llm_key.sql 只加了 secret 一列,因为当时的网关按 secret 寻址(block/delete 都接受{"key": "sk-..."})。后来的网关改成按不透明 id 寻址,0119_user_llm_key_id.sql 因此只加了一个可空列和一个 UNIQUE 约束,没有回填:
- 这些行继续可以推理(模型调用只需要 secret);
- 第一次被拒绝时,它们走已有的"忘记并重新铸造"(forget-and-re-mint)路径,被替换成一对完整值。
测试把这两件事都锁住了:TestForgetClearsAnIdlessRowWithoutBlocking确认空 id 的行会被清空但不会向网关发请求,TestRevokeIsANoopWithoutAnId覆盖"从未铸造"和"0119 之前铸造"两种情形(internal/ai/llmkey/resolver_test.go)。
GetUserLLMKey用coalesce把NULL折成'',让"未铸造"只有一个信号——调用方每次模型调用都去解pgtype会很啰嗦,判断空字符串就够了。
五、没有 provider 策略的密钥是死密钥
这个网关对没有provider_configs的虚拟密钥拒绝所有 provider。因此不带策略地铸造,会产出在第一次调用就被拒的凭证。
Mint的做法是:读LLM_ADMIN_TEMPLATE_KEY指定的那把虚拟密钥,把它的策略复制过来(internal/ai/llmkey/llmkey.go):
policy, err := c.policy(ctx) ... body := map[string]any{ "name": namePrefix + strconv.FormatInt(userID, 10), "description": "per-user spend attribution", "is_active": true, "provider_configs": policy, }这层间接是重点。另一种方案是把 provider 列表放进本服务的配置里,那会把 provider 词汇表放在两个地方,并让"新增一个 provider"变成一次 freehire 的部署,而不是网关自身配置的一次编辑。包里的providerConfig结构只做透传,注释明确写着"本包不认识任何 provider 的名字"。
5.1 那个必须记住的不对称:key_ids读 null、写["*"]
for i := range configs { if len(configs[i].KeyIDs) == 0 { configs[i].KeyIDs = []string{"*"} } }读接口把key_ids答成null,写接口要求["*"];把读到的 null 原样回传,会铸出一把"没有绑定任何 provider key"的密钥——它能通过认证,然后在每次调用上失败。TestMintNormalisesTheKeyAllowlistTheTemplateOmits用一份带"key_ids":null的模板响应验证了归一化结果(internal/ai/llmkey/llmkey_test.go)。
另外,模板策略为空时铸造必须失败,而不是产出一把死密钥:policy中len(configs) == 0直接返回包装了ErrUpstream的错误。这样误配置在网关侧就暴露出来,而不是表现为"每个用户的 AI 悄悄坏了"。
5.2 铸造请求体的完整字段语义
| 字段 | 来源 | 说明 |
|---|---|---|
name | namePrefix + userID | 形如freehire-user-42,是账号 id 在网关侧唯一出现的位置 |
description | 常量 | per-user spend attribution |
is_active | 常量true | 新键立即可用 |
provider_configs | 模板复制 | provider、weight、allowed_models、key_ids 原样透传 |
budgets[].max_limit | Config.MaxBudget | 仅在 >0 时下发 |
budgets[].reset_duration | Config.BudgetWindow | 仅在预算存在且窗口非空时下发 |
rate_limit.request_max_limit | Config.RPMLimit | 仅在 >0 时下发,窗口固定1m |
零值不下发是硬规则:显式发送 0 会被网关读作"什么也不允许",而不是"没有限制"。TestMintSendsNoCeilingWhenNoneIsConfigured与TestMintPassesAConfiguredCeilingThrough分别锁住两种情形。
5.3 2xx 但只有半把凭证 → 报错
if minted.Value == "" || minted.ID == "" { return Credential{}, fmt.Errorf("%w: minted an incomplete credential", ErrUpstream) }当客户端与网关之间有东西改写响应时,会出现 2xx 但字段缺失的情况。存下半把凭证会把账号永久标记为"已有凭证",同时让它不可用或不可撤销。envelope结构同时兼容两种应答形状(顶层带 key,或被virtual_key包裹),读取成本低于依赖哪一种。
六、编辑模板不会触达已铸造的密钥(这是真实事故)
**策略复制只发生一次,在铸造时。**之后没有任何东西重读模板。因此修改模板的provider_configs只影响新账号;所有已存在的freehire-user-*密钥保持它出生时的策略。
文档记录了一次真实故障(2026-10-18):模板把flagship移到了 openai,但 8532 把用户密钥仍然只允许它在 zai 上;当 zai 失去flagship别名那天,每一轮对话都以400 Unknown Model结束。用服务密钥测试看不出问题,因为助手用的是用户的密钥。
6.1 重同步操作步骤(以管理员身份对/api/governance/virtual-keys)
- 先备份完整列表:
GET到文件。 - 从模板构造请求体:
{"provider_configs": [...]},每个条目的key_ids设为["*"](就是上面的 null 陷阱)。 - 只对
(provider, allowed_models)集合与模板不同的freehire-user-*密钥执行PUT。 - 逐个比对每个
PUT响应与发出去的内容。网关会回 "updated successfully",然后仍然丢掉某个字段。 - 8 路并发时约四分之一的写入会返回 HTTP 500。跑 1–2 路并发,重复整轮直到没有任何差异。重跑是免费的,因为第 3 步会跳过已经匹配的密钥。
6.2 排查助手故障的正确姿势
- 用用户自己的密钥复现,即
users.llm_key里的值,而不是服务密钥; - 读网关的
/api/logs,其中virtual_key_name会告诉你失败的调用用了哪把密钥; - Langfuse看不到助手轮次。
没有 worker 做自动重同步,这是一个明确判断:模板很少变,一个几乎每次都什么都不改的每小时任务不值得它的成本。
七、配置开关与代码装配
配置读取在 internal/platform/config/config.go:
LLMAdminURL: os.Getenv("LLM_ADMIN_URL"), LLMAdminUsername: os.Getenv("LLM_ADMIN_USERNAME"), LLMAdminPassword: os.Getenv("LLM_ADMIN_PASSWORD"), LLMAdminTemplateKey: os.Getenv("LLM_ADMIN_TEMPLATE_KEY"), LLMUserMaxBudget: envFloat("LLM_USER_MAX_BUDGET", 0), LLMUserRPMLimit: envInt("LLM_USER_RPM_LIMIT", 0), LLMUserBudgetWindow: env("LLM_USER_BUDGET_WINDOW", "30d"),| 环境变量 | 默认 | 作用 | 未设置时 |
|---|---|---|---|
LLM_ADMIN_URL | 空 | 管理 API 根地址(如https://gw.example) | 整体关闭归因 |
LLM_ADMIN_USERNAME | 空 | 管理员用户名(Basic 认证) | 整体关闭归因 |
LLM_ADMIN_PASSWORD | 空 | 管理员密码 | 整体关闭归因 |
LLM_ADMIN_TEMPLATE_KEY | 空 | 提供 provider 策略的模板虚拟密钥 id | 整体关闭归因 |
LLM_USER_MAX_BUDGET | 0 | 每个用户密钥的预算上限 | 不下发预算字段 |
LLM_USER_RPM_LIMIT | 0 | 每个用户密钥的每分钟请求上限 | 不下发限流字段 |
LLM_USER_BUDGET_WINDOW | 30d | 预算重置周期 | 无预算时无意义 |
两个细节值得强调:
LLM_USER_MAX_BUDGET若写成"two dollars"这类无法解析的值,会被读成0(无上限),而不是 0 预算。TestLoad_AnUnparseableCeilingIsNoCeiling的注释是"一个环境变量里的笔误不应该拒绝这个部署发出的每一次调用"。- 仓库里另有多处注明
LLM_USER_MAX_BUDGET刻意保持不设置(例如 internal/api/handler/mail_recall_limit.go 附近),因为单次邮件召回会把多封正文放进一次模型调用,一刀切的预算上限会误伤正常使用。
装配点:服务端 cmd/server/main.go 把Settings的七个字段直接映射到llmkey.Config;cmd/auto-apply做同样的事并额外构造NewResolver(queries, llmKeys)。
八、Resolver:铸造、读取、忘记、撤销
8.1For—— 返回""而不是错误
func (r *Resolver) For(ctx context.Context, userID int64) string { if r == nil || r.gateway == nil || r.q == nil { return "" } stored, ok := r.read(ctx, userID) if !ok { return "" } if stored.Secret != "" { return stored.Secret } return r.mint(ctx, userID).Secret }read返回两个答案:凭证,以及存储到底能不能读(resolver.go)。这是刻意区分的:
pgx.ErrNoRows→ 返回(Credential{}, true),即"一个真实存在但还没有凭证的账号",可以铸造;- 其他 error → 记录日志并返回
(Credential{}, false),即"数据库没答话",绝不可以铸造。
原因很直接:在数据库故障期间铸造会给每个请求发一把新密钥,每一把都存不下来,每一把都留在网关上。
TestForFallsOpenWhenTheStoreCannotBeRead断言For返回""且minted == 0(resolver_test.go)。
8.2mint—— 竞争的处理方式
两个并发的首次调用都会铸造。条件认领(WHERE id = $1 AND llm_key IS NULL,配合行锁)只放行一个;输家采用赢家的值,并删掉自己铸造的那把,否则它会永远留在网关上,不花钱,却出现在每一个列表里。
claimed, err := r.q.ClaimUserLLMKey(ctx, ...) switch { case err == nil: return Credential{ID: claimed.LlmKeyID, Secret: claimed.LlmKey} case errors.Is(err, pgx.ErrNoRows): r.revoke(ctx, minted.ID) // 输家清理自己 winner, err := r.q.GetUserLLMKey(ctx, userID) ... default: r.revoke(ctx, minted.ID) // 存不下 → 立刻删 return Credential{} }revoke用的是Delete(尽力而为,失败只记一行日志),因为"铸造竞态的输家"是一把秒级、从未被存储的凭证,没有任何值得保留的东西。
测试覆盖了三类:TestForLosesTheRaceAndRevokesItsOwnKey(输家被删)、TestForRevokesACredentialItCouldNotStore(认领报错时删除)、TestClaimNeverStoresAnEmptyCredential(空 secret 是"未铸造"信号,绝不能写进去,且不得发起认领)。
8.3Forget—— 网关拒绝之后
func (r *Resolver) Forget(ctx context.Context, userID int64, secret string)顺序是先清行、后停键,每一步都有原因:
- 先
read。要按 id 去停用,而即将被清空的行是 id 唯一被写下的地方;先清空会留下一把没人能命名的活密钥。因此读失败时整个操作停止,只留日志(TestForgetLeavesTheRowAloneWhenItCannotBeRead)。 - 再
ClearUserLLMKey,条件带上"我们以为存着的那个 secret"。并发调用可能已经重铸,无条件清空会丢掉那把好凭证并把它孤儿化在网关。 - 只有当行里的 secret 仍然等于传入的 secret,且 id 非空时,才调用
gateway.Block。否则(例如并发的Revoke刚刚停用了它)什么都不做。
ForgetBlock 而不是 Delete:一次拒绝在两种情形下看起来完全一样——网关确实忘了这把密钥(常见情况),或者同一账号的注销刚刚把它停用了。后者有值得保留的花费历史,Delete 会偶发地抹掉它。而 Block 一把已经停用或不存在的密钥是安全的空操作。
8.4Revoke—— 账号注销
func (r *Resolver) Revoke(ctx context.Context, userID int64) error { if r == nil { return nil } stored, ok := r.read(ctx, userID) if !ok { return fmt.Errorf("%w: cannot read the credential to revoke", ErrUpstream) } if stored.ID == "" { return nil } return r.gateway.Block(ctx, stored.ID) }与包内其他地方不同,这里读失败必须返回错误:别处读失败只损失归因,这里会让注销操作"报告成功却什么都没撤销",留下一把仍在为一个已不存在的账号花钱的活密钥。而删除账号行正是让 id 不可恢复的那一步,所以这是任何人最后一次能行动的时刻。
行本身故意不动:它即将被账号级的级联删除清掉,先清只是多一次写。网关侧留下的是一个被停用、标着内部数字 id 的密钥——账号行消失后,这个 id 映射不到任何人。
注销链路在 internal/identity/accountdelete/accountdelete.go 中通过WithGatewayKeys(llmKeys.Revoke)接入(见 internal/api/handler/handler.go),和 Google 授权的撤销一样是尽力而为:离开的成员必须停止花钱,但不必等一个第三方系统。
九、Block 与 Delete 的分工
| 动作 | 方法 | 语义 | 适用场景 |
|---|---|---|---|
Block | PUT .../{id}+{"is_active": false} | 停止花费,保留在网关列表中 | 账号注销;Forget清理被拒凭证 |
Delete | DELETE .../{id} | 彻底抹掉 | 铸造竞态的输家;无法再被任何东西命名的密钥 |
文档里诚实地记录了理由的弱化:用量日志按凭证 id 索引,并且比凭证本身活得更久,所以删除不再像以前那样抹掉历史。Blocking 现在买到的更多是可读性——一把退役的密钥仍然留在网关的列表里,看得出它曾经是什么。
两者都把 404 当作"已经完成":Block/Delete里errors.Is(err, ErrUnknownKey)返回nil,因为那正是调用方想要的状态,注销流程不能因为到达该状态就记一条故障。
十、401 的两种含义绝不能混为一谈
- 按用户调用上的 401:网关忘了这把密钥 → 该重铸;
- 管理调用上的 401:我们自己的管理员密钥错了。
混淆二者,会让一个打错的环境变量被读成"每个账号的密钥都过期了",从而触发一场重铸风暴。
这是通过结构而不是哨兵值分开的:do只把 404 分类为ErrUnknownKey,其余所有非 2xx(包括 401)都进通用的ErrUpstream分支(llmkey.go)。TestARefusedAdminCallIsNotAnUnknownUserKey就是这条规则的断言。
真正要紧的那个 401——网关在推理调用上拒绝用户凭证——发生在internal/platform/llm的传输层:attribution.RoundTrip收到 401 且存在 fallback 时,用服务凭证重放一次(且只重放一次),同时调用onRefused,也就是Resolver.Forget,把那一行清掉,下次调用重铸(internal/platform/llm/credential.go)。
两条容易忽略的细节:
- 重放会重新
stamp维度标签。一次丢失维度的重试会把花费记在"什么都没有"下面,而重试恰恰是后来有人会问"发生了什么"的场景。 - fallback 永远是真实的服务凭证,绝不会是另一个用户的 secret。如果
c.fallbackKey已存在(客户端已绑定过某用户),它被原样继承;否则链式base.As(userA).As(userB)会让 userB 的 fallback 指向 userA 的密钥,一次拒绝就会静默地在 userA 名下继续花钱。
十一、绑定入口:Bind与Caller
bind.go只有两个导出函数,但它们承载了一整条规则。
func Bind(ctx context.Context, keys *Resolver, client *llm.Client, userID int64, dims ...llm.Dimension) *llm.Client { if client == nil { return nil } return client.AsCaller(Caller(ctx, keys, userID, dims...)) }Bind的用途是"调用方还没有自己的客户端,准备基于这个构建一个"。已经持有配置好的客户端的调用方不能走这里——它会被换成一个不同的客户端,丢掉那份配置。这类调用方用Caller拿到身份,然后应用到自己的客户端上。
这不是理论洁癖。Caller的引入直接来自一次线上代价:结构化简历提取声明了 120 秒预算(resumeExtractLLMTimeout),却对每个登录用户跑在 90 秒默认值上——足够在一份长简历上超时,让候选人既没有落库的经历、也没有匹配分析、也没有任何解释。llm.Caller只携带"谁",内部没有客户端可以被替换,所以调用点不可能再犯这个错误。internal/api/handler/user_llm.go里llmBinding.bind/llmBinding.caller就是这两个函数之上的薄封装,而不是第二套实现。
Caller的另一个关键点:
forget := func() { keys.Forget(context.WithoutCancel(ctx), userID, secret) }清理运行在脱离调用方 ctx 的上下文上。拒绝最可能恰好在用户关掉标签页时到来(对队列触发的调用方,则是运行自身 deadline 已过之时),而一个被网关拒绝的密钥此时也必须被清掉,否则该账号之后每次调用都在为一次被放弃的请求付账。TestBindForgetsARejectedCredentialAfterCancellation用cancel()之后仍发起的GenerateJSON验证了这一点(bind_test.go)。
十二、特性标签(feature dimension)的写法
标签由llm.Feature(value)构造,最终变成请求头x-bf-dim-feature: <value>(internal/platform/llm/credential.go)。
三条规则:
- 值是裸词,不带
feature:前缀。头部名称已经指明了维度;值里再带前缀,就会把花费归档到它自己那个"两段式"标签下,而不是与其他人并列。 - 一件事一个标签,不要给两个面向不同用户的界面用同一个标签,否则报告不再回答它存在要回答的问题。
- 标签常量放在调用方旁边:请求驱动的界面在 internal/api/handler/user_llm.go(
assistant、match-analysis、cv-extract、ats-review、autofill、mail-recall、search-intent、cover-letter),队列驱动的自动投递在 internal/api/atsapply/client.go(tagAutoApplyDrafting = "auto-apply-drafting")。
CV 定制(tailoring)刻意没有自己的标签:它不发起自己的模型调用,端点是铸造一份 CV 并扣积分,实际工作是一个tailorpreset 下的助手轮次——已经带标签了。第二个标签会重复计算同一笔花费。
一个必须遵守的写法约束:空名字或空值的维度会被stamp跳过而不是发送。x-bf-dim-: value不是一个网关能分组的头,空值与缺失在远端无法区分。
十三、按用户调用的第五步:新增功能时的检查清单
AGENTS.md的 "Adding a per-user LLM call" 给出五条,可以直接当 code review checklist:
- 通过
Bind解析(bind.go)——这是唯一需要 grep 的标识符。internal/api/handler的llmBinding.bind只是每请求的薄封装,不是第二份实现。 - 给它一个 feature 标签,常量放在调用方旁边。裸词、一表面一标签。
- 只有
cmd/server与cmd/auto-apply可以 import 这个包——scope_test.go结构性强制。新的二进制要按名字把自己加进豁免表并附上理由;检查故意不是一个会意外放行的模式。 - 如果组件在构造时就持有客户端,给它一个一行的
As(*llm.Client)克隆,而不是把第二个客户端穿进构造函数。 - 在开流之前解析。铸造是一次网络调用(超时上限 5 秒,
requestTimeout);在响应头已经发出之后再铸造,会让客户端正在读取的流卡住。
第 1 条之外还有一层服务端的护栏:llmBinding.bind/caller失败关闭(fail closed)——上下文未被付费 AI 闸门(mw.paidAI)标记时不会拿到任何客户端,返回的是 500 而非升级提示(internal/api/handler/user_llm.go)。这是把"每次用户归因的模型调用都经过这里"变成结构事实的另一半。
十四、Activity:用量是从网关读的,不是自己记的
花费实际落在网关自己的请求日志里,每次调用一行,按凭证 id 索引。GET /api/logs/stats在窗口上聚合它——这就是/me/usage读的东西,也是本包不写自己的成本表的原因。
window := url.Values{ "virtual_key_ids": {keyID}, "start_time": {from.UTC().Truncate(24 * time.Hour).Format(time.RFC3339)}, "end_time": {to.UTC().Truncate(24 * time.Hour).Add(24*time.Hour - time.Nanosecond).Format(time.RFC3339Nano)}, }四个被测试钉死的实现细节:
- 两次调用,不是一次。网关报的是成功率而不是失败数,反推回去要做四舍五入:9 次里失败 7 次是 77.77…%,把显示值还原成"2 次失败"是在对展示数字做算术。第二次调用把同样的窗口加上
status=error过滤,直接读计数。 - 窗口两端扩到整天。调用方按"日期"工作,网关按"时刻"工作;照字面取 from/to 会静默丢掉第一天更早、最后一天更晚的一切。结束时间是当天最后一个瞬间(
...T23:59:59.999999999Z),而不是 23:59:59。 - 空 id 直接短路为零,不问网关——这既是"从未用过 AI 的账号",也是"0119 之前的行"。
- 不返回任何金额。
Activity只有Requests / Failed / Tokens。网关的成本数字是面对混合上游池的标价,不是我们的成本,更不是用户的价格(用户的价格用积分计量)。
14.1 读这个数字前必须知道的两个后果
- 读取只作用于一把凭证。网关没有"按账号查询"的能力,所以一把月中被替换的密钥会让它之前的调用掉出总计。重铸只在网关拒绝已存密钥时发生,所以罕见;修复方案(记住已退役 id 的列表)会让死凭证长期存活,只为让一个计数器稍微完整一点。
- 日志写入是异步的,大约在调用后 5 秒落地。跨月看无害,任何 read-after-write 假设都是致命的。
usageHandlers.usage用plan.Day(now)与plan.ResetsAt(now)取周期,和配额重置周期同源,避免"配额和用量按不同周期统计,两个看起来都对却互相矛盾"。
14.2 按功能拆成本不是 API 问题
feature维度会进日志行、进 OpenTelemetry span,并且——只有当feature被列在网关自己的prometheus_labels里时——才会进指标。"定制一份 CV 花多少钱"因此是一个 Grafana 查询;/api/logs/histogram/cost/by-dimension只按 provider、team、customer、user 和 business unit 聚合。
再强调一次边界:**这些数字是标价,不是账单。**网关按它的模型表定价,而它背后的池子跑着混合的上游密钥。它们可以诚实地比较不同功能和不同周期,绝不能当作发票引用。
十五、验证这套机制:可以跑哪些测试
包内的测试不需要外部依赖,全部用httptest假网关 +fakeQueries:
go test ./internal/ai/llmkey/... go test ./internal/platform/config/ -run 'LLM|GatewayAdmin|Ceiling'阅读顺序建议:先看llmkey_test.go(客户端与路由形状、模板策略、半凭证、Activity 窗口),再看resolver_test.go(竞态、读失败、忘记与撤销),最后看bind_test.go——它用一个spendProxy记录真实发出的Authorization和X-Bf-Dim-Feature,因为"客户端自己的字段报告的是它相信的东西,而不是它发出去的东西"(bind_test.go)。
想验证结构性约束本身:
go test ./internal/ai/llmkey/ -run TestBackgroundEntryPointsResolveNoUserCredential -v如果想验证一次改动没有引入漏归因的调用,grep 这两个标识符就够了:llmkey.Bind(与llmkey.Caller(;新增的后台二进制若出现在cmd/下,scope_test.go会先把构建拦住。
结语
internal/ai/llmkey的规模不大,但它几乎每一行都在处理"归因是记账,不是功能"这条前提带来的后果:nil客户端是正常部署、For返回空串而不是错误、数据库读不出时不铸造、401 的管理面与推理面彻底分开、已花费的凭证停用而不是删除、模板编辑后必须手工重同步。
把这些规则当成契约来读,你就能在这个仓库里安全地新增一个按用户计费的模型调用;而把它们当成可选的风格建议,你已经看到了后果——8532 把用户密钥在某个 10 月的一天集体变成400 Unknown Model。
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
freehire 的 AI 成本治理设计:每用户 LLM 虚拟密钥、三档每日额度与 402 拒绝指南
freehire 的 AI 成本治理设计:每用户 LLM 虚拟密钥、三档每日额度与 402 拒绝指南 freehire 是一个开源职位搜索引擎:它从公司自己的
H2O LLM Studio 因果回归实验卡片:regression_head 推理用法与模板变量机制全解
H2O LLM Studio 因果回归实验卡片:regression_head 推理用法与模板变量机制全解 本文围绕 H2O LLM Studio(h2o ll
微调大模型AI 应用用 PostHog 探索 LLM 成本:从总花费、模型/用户/链路分解到回归告警的完整实战指南
用 PostHog 探索 LLM 成本:从总花费、模型/用户/链路分解到回归告警的完整实战指南 PostHog 会在采集(ingestion)阶段为每一条 $a
数据分析数据可视化后端前端可观测性
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考