Dayward AI
Week 3 · D19About 6 hours

Cross-Service Agent Integration: Minting a User-Level JWT, JWKS Signature Verification, the inject/memory/usage Interfaces, Idempotent externalId

Connect the mini-multi-agent service to mini-koda: mint a user-level JWT, verify signatures with JWKS, and integrate the inject, memory, and usage interfaces while guaranteeing idempotency.

Today's goals 0/3

Sign in to tick these off and save your progress.

今日目标

  1. 能实现用户级 JWT 的铸造流程,并说明它和服务级令牌的区别
  2. 能实现用 JWKS 验签,验证跨服务请求的合法性
  3. 能对接 inject/memory/usage 三类接口,并用 externalId 保证幂等

昨天那张图已经能自己跑完、能压缩历史、能从检查点恢复。但它到现在为止还是一座孤岛:所有输入都来自你手动喂进去的一句话。今天把它和 W2 那套 Agent 平台接上——这是本课两个里程碑项目第一次连起来。读完回到页面顶部,把上面三条勾掉。

小白版讲解

你在别国办事,出示的是自己的护照

先把今天要连的两头摆清楚。W2 建的 mini-koda 是一个 Agent 平台:它有会话、有执行记录、有长期记忆、有成本台账,用户的数据都在它这儿。W3 建的多 Agent 编排服务是另一个进程、另一个仓库、另一支团队在管,它跑完一轮分诊和退款草案之后,需要把结论写回用户的会话里,也需要读一读这个用户以前说过什么。

于是问题变成:两个服务之间凭什么信任对方?

最省事的答案是发一把钥匙。平台生成一个长长的随机字符串交给编排服务,对方每次请求带上它,平台一比对,对上就放行。这就是服务级令牌(service token),也是绝大多数团队上线第一版时的做法。它的问题不在于不安全,而在于一旦出事,你什么补救手段都没有

换成出国办事的场景就一目了然了。服务级令牌相当于 B 国给 A 国某家公司发了一把万能钥匙:拿着它的人可以走进任何一间办公室、调阅任何一份档案。而正常的做法是每个人带自己的护照入境,边检看的是这个人是谁、来做什么、签证有效期到哪天。

用户级令牌(user token)就是那本护照。 它由编排服务签发,但代表的是某一个具体用户,里面写着这个用户的 id 和这次能做哪些事。三个理由让它不是「更规范一点」而是「必须这么做」:

第一,爆炸半径。服务级令牌泄露一次,等于全平台所有用户的数据一起泄露,而且你连是从哪儿漏的都不知道;用户级令牌泄露一张,最多丢一个用户的数据,而且十五分钟后自动作废。

第二,审计追不到人。事故复盘时你只能查到「编排服务在 03:14 调了一次写接口」,查不到它当时替谁在操作。有了 sub 这个字段,每一条访问日志都能落到具体用户身上。

第三,下游没法做用户级权限判断。平台这边的 memory 接口要回答「该返回谁的记忆」,如果身份只能从请求体里读,那就等于把这个问题交给了调用方自己回答——而调用方是可以写错、也可以撒谎的。

那这本护照里该写什么?答案比多数人想的要少得多。JWT 的载荷是 base64 编码,不是加密,任何人拿到 token 都能直接解开看:

TextText
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFnZW50LWtleS0xIn0.eyJzY29wZSI6...
  ↓ 把中间那段 base64 解开,不需要任何密钥
{
  "sub": "u-1",
  "iss": "http://127.0.0.1:4019",
  "aud": "mini-koda",
  "scope": "inject:write memory:read",
  "jti": "5968-2b94-2db3-4fe0",
  "iat": 1788566547,
  "exp": 1788567447
}

本课的口径固定成这五个字段(外加签发时间 iat 与过期时间 exp):sub 是用户 id,iss 是谁签的,aud 是签给谁用的,scope 是这次授权做哪几件事,jti 是这张令牌自己的编号。业务数据一个都不许进来。 两个理由,都很硬:一是它根本藏不住,把用户手机号、会员等级写进去等于明文发在公网上;二是它写进去的那一刻就过时了——用户十分钟后降级成免费版,那张令牌里还写着「会员」,而它要到十五分钟后才失效。令牌只回答「你是谁、能做什么」,「你现在是什么状态」永远要去数据库现查。

护照要能用,前提是边检能验出真伪。但边检不会为了核实一本护照就打个越洋电话回签发国——那怎么验?

边检验的是公开发布的证书,不是打电话回签发国

先看不该怎么做:两边约定一个共享密钥,签的时候用它、验的时候也用它(对称签名,比如 HS256)。这在只有两个服务时能跑,但它有三个躲不掉的麻烦。

密钥轮换必须两边同时改。 你要换密钥,就得和对方约一个时间点一起发版,中间那几秒钟必然有请求验签失败。真做过这件事的人都知道,这种「两个团队同时上线」的操作在生产上基本等于不轮换。

验签方拿到的是签名能力。 对称密钥意味着平台这边也能签出任意一张 token——包括伪造一张 sub 是任何人的。平台被入侵,攻击者拿到的不只是读权限,是冒充任何用户的能力

调用方多一个,密钥就多一份。 三个下游服务就是三份共享密钥散在三处配置里,任何一处泄露都得全体轮换。

非对称签名把这三件事一次解决:签发方持私钥,验签方只拿公钥。 公钥泄露了也无所谓——它只能验签,不能签。轮换时签发方先把新公钥挂上去,两把公钥并存一段时间,等旧 token 自然过期再摘掉旧的,全程不用通知任何人。

公钥怎么送到验签方手里?就是 JWKS(JSON Web Key Set):签发方在一个固定地址上挂一份公钥集合,本课统一用 /.well-known/jwks.json。任何人都能拉,因为里面本来就只有公钥:

JSONJSON
{
  "keys": [
    {
      "kty": "RSA",
      "n": "q4sEvqo5Uu5AVgp8DzXgiW7thpERtiXDr8g1ama8n0xR02cZj7kNi3fu...",
      "e": "AQAB",
      "kid": "agent-key-1",
      "use": "sig",
      "alg": "RS256"
    }
  ]
}

kid 是这把钥匙的名字。签发时写进 token 的头部,验签方按 kid 从集合里挑对应的那一把——这就是轮换不停机的全部机密:新旧两把公钥同时挂着,新签的 token 指向新 kid,老 token 指向老 kid,两边都验得过。

先看签发方怎么铸这张令牌。本课固定 RS256、有效期十五分钟:

issuer.js
import { randomUUID } from 'node:crypto'
import { SignJWT } from 'jose'
 
const ISSUER = 'http://127.0.0.1:4019'
const AUDIENCE = 'mini-koda'
const TOKEN_TTL_SECONDS = 900 // 15 分钟:够跑完一次跨服务调用,泄露了也很快作废
 
export async function mint(userId, scopes, privateKey) {
  const now = Math.floor(Date.now() / 1000)
  // 只有这五个 claim。用户名、会员等级一律不进来:JWT 是 base64,不是加密。
  return new SignJWT({ scope: scopes.join(' ') })
    .setProtectedHeader({ alg: 'RS256', kid: 'agent-key-1' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setJti(randomUUID())
    .setIssuedAt(now)
    .setExpirationTime(now + TOKEN_TTL_SECONDS)
    .sign(privateKey)
}

验签这一侧只做一件事:按 kid 取公钥、验签名、顺手把 iss、aud、exp 三样一起校验掉,然后从载荷里取出身份。

verify.js
import { createRemoteJWKSet, jwtVerify } from 'jose'
 
// 远端公钥集合。jose 会缓存,并在遇到没见过的 kid 时重新拉取——
// 所以对方轮换密钥时,这边一行配置都不用改,更不用重启。
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
 
export async function callerOf(token) {
  // 验签的同时校验 iss / aud / exp。少校验一项都会开一个口子:
  // 不验 aud,别人签给第三方服务的 token 也能拿来打你。
  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ['RS256'],
  })
  return {
    userId: String(payload.sub),
    issuer: String(payload.iss),
    scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
    jti: String(payload.jti),
  }
}

四份代码有一个共同点值得单独指出:四个库都要求你显式传 issuer 和 audience,而不是默认帮你校验。这不是设计缺陷,是因为库不知道你的服务叫什么。漏掉 audience 是跨服务集成里最常见的安全事故——签发方给别的下游服务签的令牌,签名一样是合法的,你不校验 aud 就等于替别人的接口开了门。

inject:把一条外部事件放进这个用户的会话里

护照上写着入境事由,能办的事只限于那一项。scope 就是事由栏:一张只被授予 inject:write 的令牌,拿去查成本会被挡在门外。今天开三类接口,对应三种事由。

第一类是 inject——编排服务跑完一轮,把结论作为一条消息注入用户的会话。请求长这样:

JSONJSON
POST /v1/inject
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
{
  "externalId": "evt-20260905-0001",
  "text": "已核对订单 A1024,物流停留在中转站,建议先补发。"
}

请求体里没有 userId,这不是省略,是本课贯穿始终的一条安全不变量:身份只能来自令牌,不能来自请求体。 D12 讲长期记忆时你已经见过它的另一面——memory_search 这个工具不给模型任何身份参数,用户 id 由服务端从会话里取。理由完全相同:凡是让调用方自己填的字段,就是可以被填成别人的字段。请求体是调用方随便写的,令牌是签过名的,两者的可信度差着一整个密码学。

服务端拿到之后做三件事:按 sub 找到(或新建)这个用户的会话,把消息按 seq 追加进 messages 表(D8 定的那张,本天不改),返回会话 id 和序号:

JSONJSON
201 Created
{ "status": "created", "userId": "u-1", "sessionId": "s-1", "seq": 0,
  "externalId": "http://127.0.0.1:4019|u-1|evt-20260905-0001" }

工程代价有两条要提前想清楚。一是注入是有成本的:每注入一条消息,很可能触发平台这边一次新的执行,也就是一次真金白银的模型调用。所以这个接口必须限流,而且限流的维度是「每用户每分钟」而不是「每调用方每分钟」——否则一个用户的异常重试会把所有人的额度吃光。二是要防回环:编排服务注入一条消息,平台执行完又回调编排服务,后者再注入一条……两个服务能把彼此拉进一个无限循环,账单是唯一会提醒你的东西。最简单的止血办法是给注入的消息打上来源标记,平台侧看到来源是编排服务就不再回调。

memory:跨服务读写用户记忆,但只读得到该读的那部分

第二类接口是 memory,对应 D12 建的那张 memories 表。编排服务在拟退款方案之前,想知道这个用户以前有没有说过「不接受换货」——这条信息在平台这边。

这里最值得说的不是怎么读,而是为什么 scope 要拆成 memory:readmemory:write 两个。读和写的风险完全不对称:读错了泄露信息,写错了污染数据,而且被污染的记忆会在此后每一次对话里持续影响模型的判断,比一次泄露更难发现、更难回滚。拆成两个 scope 之后,一个只需要读记忆的子 Agent 就永远拿不到写的能力——最小权限不是一句口号,它的落地形态就是「这张令牌里到底写了几个 scope」

具体到铸造环节:编排服务替某个用户铸令牌时,只写这一次任务真正需要的那几个 scope。分诊阶段只需要 memory:read,到了要沉淀结论时再铸一张带 memory:write 的新令牌——反正十五分钟就过期,多铸一张的代价接近于零。

还有一条容易被忽略的边界:跨服务读记忆,读到的应该是「与本次任务相关的那部分」,而不是这个用户的全部记忆。 平台这一侧应当支持按 query 检索并限制返回条数,而不是提供一个「把这个人的所有记忆倒出来」的接口。一旦提供了,它迟早会被某个图省事的调用方用成默认写法,而那时候的爆炸半径就又回到了服务级令牌的量级。

usage:把账单口子开在服务端,不开在调用方

第三类是 usage,读的是 D13 那张 usage_ledger 台账。编排服务想知道这个用户这个月花了多少钱,好决定要不要降档到便宜模型。

台账的字段 D13 已经定死:user_idrun_idmodelkindprompt_tokenscompletion_tokenscost_usdcreated_at。跨服务这一层要做的只有一次聚合:

SQLSQL
select count(*)                              as calls,
       sum(prompt_tokens)                    as prompt_tokens,
       sum(completion_tokens)                as completion_tokens,
       sum(cost_usd)                         as cost_usd
from usage_ledger
where user_id = $1;

注意那个 $1 从哪来——它来自令牌的 sub,不是来自查询参数。这是本章三个接口里最容易写错的一个GET /v1/usage?userId=u-2 看起来太自然了,自然到评审时都未必有人多看一眼。而它的后果是任何一个拿到任意一张有效令牌的人,都能遍历所有用户的消费金额。

另外两个设计决定:聚合在服务端做,只返回汇总,不返回明细。 明细里有 run_id 和 model,等于把平台的执行细节和选型策略一并交出去了;调用方真正需要的只是「这个月花了多少」这一个数。以及金额用定点数,不用浮点。 今天实验里在内存中一律按微美元整数加总,落库那一步才写成 numeric 的六位小数——理由 D13 讲过,这里只是把同一条规矩带过跨服务边界。

externalId:同一招,第三次用

最后一块拼图是幂等。跨服务调用一定会重复:网络超时后调用方重试、消息总线至少一次投递、对方发版时把在途请求重放一遍——同一个事件到达两次是必然,不是意外

对策你已经用过两次了。D8 用 runs.idempotency_key 上的唯一约束挡住重复的用户消息,D13 用同一招挡住被两个调度器同时触发的 cron tick。今天是第三次:给外部事件一个 externalId,落进 external_events 表,唯一约束挡重复。

SQLSQL
create table external_events (
  external_id text        primary key,   -- 幂等的最终裁判,和 D8 的 idempotency_key 同一招
  kind        text        not null,
  payload     jsonb       not null,
  created_at  timestamptz not null default now()
);
 
insert into external_events (external_id, kind, payload)
values ($1, $2, $3::jsonb)
on conflict (external_id) do nothing
returning external_id;

跨服务这一层比前两次多了一个坑:externalId 是调用方自己生成的。 两个不同的调用方各自造出一个 evt-1 是迟早的事,而撞车的后果不是报错——是后来的那个用户静默收不到消息,因为他的事件被当成重复投递丢掉了,日志里干干净净。所以落库前必须加命名空间,三段都取自验签后的令牌,伪造不了:

events.js
// 谁发的(iss)、替谁发的(sub)、事件自己的 id,三段拼起来才是幂等键
export function namespacedExternalId(issuer, userId, externalId) {
  return `${issuer}|${userId}|${externalId}`
}
 
// created === false 表示这条事件之前已经处理过,调用方应当拿到和第一次相同的结果。
// 判据只有一个:插入语句有没有真的插进去。
export async function recordExternalEvent(store, caller, kind, externalId, payload) {
  const key = namespacedExternalId(caller.issuer, caller.userId, externalId)
  const created = await store.insertExternalEvent(key, kind, JSON.stringify(payload))
  return { created, key }
}

还有两个细节值得定死。externalId 必须由事件本身决定,不能是每次重试都重新生成的随机数——那等于没有幂等,这条 D8 已经讲透。重复送达返回 200 而不是 409。 重复不是错误,是分布式系统的正常现象;回 409 会让调用方的重试逻辑把它当失败处理,越重试越乱。正确做法是回 200 并附上第一次的结果,让调用方觉得自己成功了——因为它确实成功了。

源码导读

动手实验

🧪 D19 实验:mini-multi-agent → mini-koda 三接口打通

Code location: labs/agent-30days/day-19-cross-service-integration

验收标准:

  1. MOCK=1 SELFTEST=1 pnpm start 九项自检全部 ✅、退出码为 0(starter 原样跑只有第 1、3、9 项 ✅,其余六项各自点名一个练习点)。
  2. JWKS 端点只发公钥(有 n、e、kid,没有私钥参数 d),铸出的令牌 claims 恰好是 sub、iss、aud、scope、jti,有效期 900 秒。
  3. 篡改过载荷的令牌返回 401,一分钟前就过期的令牌返回 401,只被授予 inject:write 的令牌去查 usage 返回 403。
  4. 同一个用户拿同一个 externalId 投两次,第二次返回 duplicate 且只落一条记录;换一个用户用相同的 externalId 却必须成功。
  5. 请求体里写 userId 完全无效,记忆仍然落在令牌 sub 名下;usage 只汇总自己的账,合计 0.000728 美元。

两个服务都跑在同一个进程里:被调用方(mini-koda)监听 3019,签发方监听 4019,MOCK=1 下零外部服务——本天没有模型调用,唯一的外部依赖是数据库,走的是 src/infra/ 里的内存实现,external_events 那条唯一约束被真实实现了一遍,所以幂等是真的生效而不是假装生效。想跑真库就在 lab 根目录 docker compose up -d(Postgres 宿主端口 5519),设上 DATABASE_URL 再跑同一条命令,业务代码一行不变。卡住了先看自检输出——每个 ❌ 都写着该去改哪个练习点。

  1. 先跑一次 starter 的自检,看清楚 9 项里哪 6 项是红的,这就是今天要补的全部工作量。
  2. 练习 1:在 issuer.ts 里把 claims 补齐并设上 15 分钟有效期,第 2 项变 ✅。
  3. 练习 2、3:把 server.ts 的 decodeJwt 换成 jwtVerify 并校验 iss 与 aud,再把 requireScope 从放行改成判断,第 4、5、6 项一起变 ✅。
  4. 练习 4:在 events.ts 里给幂等键加上命名空间,并把插入的返回值当成唯一裁判,第 7 项变 ✅,同时观察另一个用户用相同 externalId 时的现象变化。
  5. 练习 5:把 inject 与 memory 两个 handler 里的身份改成只认令牌的 sub,第 8 项变 ✅,然后跑一次真库版确认同一份业务代码在 Postgres 上结果一致。

面试题

今天 4 道题在下方题库区,侧重用户级令牌与服务级令牌的取舍、JWKS 的原理、幂等键的设计与落地,以及跨服务 API 的职责边界。展开后先看「分析过程」再看要点——第 1 题的追问(令牌泄露了怎么办)和第 3 题的追问(幂等键该由谁生成)是最容易被追到底的两处,别跳过。

检查清单与明日预告

  • 能实现用户级 JWT 的铸造流程,并说明它和服务级令牌的区别
  • 能实现用 JWKS 验签,验证跨服务请求的合法性
  • 能对接 inject/memory/usage 三类接口,并用 externalId 保证幂等
  • 能说清「不用共享密钥」的三个理由,以及为什么令牌里不能放业务数据
  • 能说出「身份只能来自令牌」这条不变量在本课出现过的两处,以及违反它的具体后果
  • 实验的 5 条验收标准全部通过
  • 4 道面试题不看要点也能答出至少 3 道

明天(D20)让这套系统主动干活。到今天为止它一直是被动的:用户说一句、系统做一件,跨服务调用也是别人先来敲门。而真正让用户觉得「这东西有用」的往往是它自己想起来的那一次——订单卡在中转站三天了,主动问一句要不要补发。机制其实 D13 就已经备齐(中心调度、消息总线、Worker 消费),明天要解决的是另一半问题:该不该发。时区、安静时段、每日上限,这三样任何一样没做好,主动关怀就会变成骚扰,而用户拉黑一次就再也不会回来了。先把「能安全地被调用」做扎实,再谈「主动去打扰别人」——顺序不能反。

Interview questions

  • For service-to-service calls, would you use a service token or a user token? When does each apply?两个服务之间调用,你会用服务级令牌还是用户级令牌?分别适用于什么场景?
    Common in ChinaCommon overseasIntermediate#auth#security#api-design

    How to reason about it · think before answering

    1. The hinge is each. Answering user tokens are safer turns a design question into a slogan — the interviewer wants the conditions under which each one is correct, and the concrete cost of choosing wrong.
    2. Give the deciding question first: is there a specific user behind this call? If yes, it must be a user token. If not — fetching config, reporting metrics, running a reconciliation batch — a service token is the right answer, and stuffing in a user id would fabricate audit history.
    3. Then state the three reasons as costs, not virtues. A leaked service token means every user's data at once; a leaked user token means one user, and it expires in fifteen minutes. Audit logs with a service token only show that some service called, never on whose behalf. And a downstream service doing per-user authorization is forced to trust a userId in the request body, which the caller writes freely.
    4. Add the production view: it is rarely either-or. Real systems use the service credential to obtain user tokens — the caller proves who it is once, then mints a short-lived token representing one user. The service credential then appears only at the minting step, never on every business call.
    5. Expect: what if a token leaks? Answer in two layers — a short lifetime (fifteen minutes here) does most of the containment, and a jti denylist is the supplement. Do not lead with a denylist: it puts a database lookup in front of every verification and gives away the whole point of stateless verification.
    6. Expect: how fine-grained should scopes be? Offer a usable rule — split along asymmetric risk. A bad read leaks information; a bad write poisons data that keeps influencing every later turn. So read and write always split; finer than that only if a real caller genuinely needs just one half.

    分析过程 · 先想清楚再作答

    1. 题眼在「分别」。答「用户级更安全」就把一道设计题做成了口号题——面试官想看你能不能说出两者各自成立的条件,以及选错的具体代价。
    2. 先给判断依据,一句话就能拆开:这次调用**有没有一个具体的用户在背后**。有,就必须是用户级;没有(拉配置、上报指标、跑对账批处理),服务级才是对的,硬塞一个用户 id 进去反而是伪造审计记录。
    3. 然后把用户级的三条理由说成代价而不是优点:服务级令牌泄露一次等于全量用户数据泄露,用户级泄露一张只丢一个用户且十五分钟自动作废;服务级在审计日志里只能查到「某服务调了一次」,查不到替谁操作;下游做用户级权限判断时,服务级令牌逼着它去信请求体里的 userId,而那是调用方可以随便写的。
    4. 补一句生产视角:两者不是二选一,真实系统里常常是「服务级令牌用来换用户级令牌」——调用方先用自己的服务凭证证明自己是谁,再申请一张代表某个用户的短期令牌。这样服务凭证只出现在铸造这一步,不出现在每一次业务调用里。
    5. 可以预期的追问一:令牌泄露了怎么办?答案要分两层——短有效期(本课 15 分钟)是止损的主力,撤销列表按 jti 拉黑是补充;不要上来就说「用黑名单」,那等于给每次验签加一次数据库查询,把无状态验签的好处全赔进去了。
    6. 可以预期的追问二:那 scope 该切多细?给一条可操作的判据——按「读写不对称的风险」切,读错了泄露信息、写错了污染数据且会持续影响后续每一轮对话,所以 read 和 write 必须分开;再细就要看有没有真实的调用方只需要其中一半。

    Key points

    • The deciding question is whether a specific user stands behind the call: yes means user token, no (config, metrics, reconciliation) means service token
    • A leaked service token exposes every user; a leaked user token exposes one and expires on its own
    • Auditing has to reach a person — only the sub claim answers who the call was made on behalf of
    • With a service token the downstream must trust a userId in the request body, which the caller can forge
    • Common production shape: the service credential only buys short-lived per-user tokens and never appears on business calls
    • After a leak, short lifetimes do the containment and a jti denylist supplements it — do not trade away stateless verification by default

    答题要点

    • 判断依据是「这次调用背后有没有一个具体用户」:有就用用户级,没有(配置、指标、对账批处理)才用服务级
    • 服务级令牌泄露的爆炸半径是全量用户,用户级只影响一个用户且短期自动失效
    • 审计要能落到人:只有 sub 字段能回答「当时是替谁操作的」
    • 下游要做用户级权限判断时,服务级令牌逼着它去信请求体里的 userId,而那是调用方可以伪造的
    • 生产里常见组合:服务凭证只用来换取代表某个用户的短期令牌,不出现在每次业务调用里
    • 泄露后的止损顺序是短有效期优先、jti 撤销列表补充,别一上来就上黑名单换掉无状态验签
  • How does JWKS-based verification work, and why does it fit cross-service scenarios better than a shared secret?JWKS 验签是怎么工作的?为什么跨服务场景下它比共享密钥更合适?
    Common in ChinaCommon overseasBasic#auth#jwt#security

    How to reason about it · think before answering

    1. This is the giveaway question of the chapter, but it still separates people: can you turn key rotation into a concrete operational sequence rather than saying it is easier to manage?
    2. Describe the mechanism in three sentences. The issuer holds the private key and signs; the public key set is published at a fixed address (/.well-known/jwks.json here); the token header carries a kid, and the verifier picks the matching public key from the set. Verification needs only public material, so the endpoint is public by design.
    3. Then give three reasons, each as an operational action: rotation needs no synchronized deploy on both sides (publish the new public key, let both coexist, drop the old one after old tokens expire); the verifier holds verification power, not signing power, so compromising it does not let anyone forge tokens; and adding a caller does not scatter another copy of a secret.
    4. Volunteer the part people forget: verifying the signature is not the whole check. A valid signature only proves the issuer signed it. You still validate iss, aud and exp — and missing aud is the most common cross-service incident, because a token the issuer signed for a different downstream is equally well signed, so skipping audience means holding the door open for someone else's API.
    5. Two engineering details worth adding: cache the key set but refetch on an unknown kid, or rotation day becomes a mass failure; and allow a small clock skew on exp, but not so large that it cancels out the point of short lifetimes.
    6. Expect: so is HS256 unusable? Answer that it is fine when one service signs and verifies its own tokens, and it is faster. The criterion is whether signer and verifier sit in the same trust domain; across domains, asymmetric is mandatory. Framing it as a trade-off shows judgment rather than memorization.

    分析过程 · 先想清楚再作答

    1. 这是本章的送分题,但送分题也有区分度:能不能把「密钥轮换」这件事讲成一个具体的运维动作,而不是一句「更方便管理」。
    2. 先讲机制,三句话:签发方持私钥签名,公钥集合挂在一个固定地址上(本课用 /.well-known/jwks.json);令牌头部带一个 kid,验签方按 kid 从集合里挑对应的公钥;验签只用公钥,所以这个地址是公开的,谁都能拉。
    3. 再讲为什么比共享密钥好,三条都要落到运维动作上:轮换不用两边同时发版(新旧两把公钥并存一段时间,等老令牌自然过期再摘旧的);验签方拿到的只是验签能力而不是签名能力,被入侵也伪造不出令牌;多一个调用方不用多散一份密钥出去。
    4. 然后主动补上最容易被忽略的一段:验签不等于验完。签名合法只说明「这确实是那个签发方签的」,还必须校验 iss、aud、exp——**漏掉 aud 是跨服务集成里最常见的事故**,因为签发方给别的下游服务签的令牌,签名一样合法,不校验受众就等于替别人的接口开门。
    5. 工程细节可以再加两条:公钥集合要缓存,但遇到没见过的 kid 要能主动重拉,否则轮换那一刻会集体失败;以及时钟偏移,exp 校验要留一点容忍度,但容忍度不能大到把短有效期的意义抵消掉。
    6. 可以预期的追问:那 HS256 是不是就不能用了?答「同一个服务自己签自己验时它没问题,而且更快」——判据是签名方和验签方是不是同一个信任域,跨了域就必须非对称。这么答显得你在做权衡而不是背结论。

    Key points

    • Mechanism: private key signs, public key set sits at a fixed URL, the token header carries a kid, the verifier selects by kid
    • Rotation needs no synchronized deploy: publish the new key, let both coexist, retire the old one after old tokens expire
    • The verifier gets verification power only, never signing power, so compromising it cannot forge tokens
    • Adding callers does not scatter more secrets; the public key being public is the design intent
    • Beyond the signature you must check iss, aud and exp — skipping aud opens your API to tokens signed for someone else
    • Cache the key set but refetch on an unknown kid; HS256 is still reasonable when one service signs and verifies its own tokens

    答题要点

    • 机制:私钥签名、公钥集合挂在固定地址、令牌头部带 kid、验签方按 kid 取公钥
    • 轮换不用两边同时发版:新旧公钥并存,等老令牌自然过期再摘旧的
    • 验签方只拿到验签能力而不是签名能力,被入侵也伪造不出令牌
    • 调用方增加不需要多散一份密钥,公钥公开本来就是设计意图
    • 验签之外必须校验 iss、aud、exp,漏掉 aud 等于替别的下游服务开门
    • 缓存公钥集合但要能按未知 kid 主动重拉;HS256 在同一信任域内自签自验仍然是合理选择
  • How do you design an idempotency key for cross-service calls — who generates it, where does it live, and what do you return on a repeat?跨服务调用的幂等键该怎么设计?由谁生成、存在哪、重复了返回什么?
    Common in ChinaCommon overseasIntermediate#idempotency#distributed-systems#api-design

    How to reason about it · think before answering

    1. This question separates people entirely on implementation detail. Anyone can define idempotency; answering who generates the key, where it lives, and what a repeat returns shows whether you have actually built one.
    2. Start with the rule: the final arbiter must be a database uniqueness constraint, not an application-level check-then-insert. Check-then-insert always passes single-process tests and produces duplicates the moment you run two replicas — both check, both find nothing, both insert. The window is too narrow to reproduce under load testing and wide enough to produce dirty rows daily in production.
    3. Who generates it: the caller, because only the caller knows that two retries are the same event. But the key must be derived from the event itself, never a fresh random UUID per retry — that is idempotency in name only. Same criterion as the user-message case from day 8.
    4. Cross-service adds one trap worth the most points: never use the caller's raw id as the key. Two different callers will eventually both produce evt-1, and the failure is not an error — the second user silently receives nothing, because their event is treated as a duplicate and the logs look clean. Namespace it: issuer plus user id plus event id, all three taken from the verified token so none of them can be forged.
    5. What to return also matters: a repeat gets 200 with the original result, not 409. Repeats are normal in distributed systems; a 409 makes the caller's retry logic treat it as a failure and the situation compounds.
    6. Expect: does this table grow forever? Yes, so give it a retention window — a TTL matching the replay window the business tolerates, say seven days, with periodic cleanup. Say plainly that a duplicate arriving after cleanup is treated as new; that is a stated trade-off, not a hole.

    分析过程 · 先想清楚再作答

    1. 这题的区分度全在实现细节上。概念谁都会说,能不能答对「谁生成、存在哪、返回什么」这三个具体问题,直接暴露你有没有真做过。
    2. 先立一条铁律:**幂等的最终裁判必须是数据库的唯一约束**,不是应用层的「先查一下有没有」。先查后插在单进程测试里永远是对的,一上多实例就出双份——两个副本同时查、同时发现没有、同时插入,这个时间窗压测时窄到复现不出来,上线后每天出几条脏数据。
    3. 再答「谁生成」:由**调用方**生成,因为只有它知道重试的那两次是同一件事;但键必须由事件内容决定,不能是每次重试重新生成的随机 UUID——那等于没有幂等。这条和 D8 的用户消息幂等是同一条判据。
    4. 跨服务比同服务多一个坑,这是本题最有价值的一点:**调用方给的 id 不能直接当键用**。两个不同的调用方各自造出 evt-1 是迟早的事,撞车之后的表现不是报错,而是后来那个用户静默收不到消息——他的事件被当成重复丢掉了,日志里干干净净。所以落库前要加命名空间,用「签发方 + 用户 id + 事件 id」三段拼,而且三段都取自验签后的令牌,伪造不了。
    5. 「返回什么」也是个坑:重复送达要返回 200 并附上第一次的结果,不要返回 409。重复不是错误,是分布式系统的常态;回 409 会让调用方的重试逻辑把它当失败处理,越重试越乱。
    6. 可以预期的追问:这张表会不会无限涨?答「会,所以要有保留期」——按业务能接受的重放窗口设一个 TTL(比如 7 天)定期清理,同时说明清理之后超期的重复请求会被当成新事件,这是一个明确的、可接受的取舍,不是漏洞。

    Key points

    • The arbiter is a unique constraint plus on conflict do nothing; check-then-insert duplicates as soon as you run two replicas
    • The caller generates the key, but it must be derived from the event — a fresh UUID per retry is not idempotency
    • Never use the caller's raw id: namespace it with issuer plus user id plus event id, all taken from the verified token
    • A collision does not raise an error; it silently drops another user's event and leaves clean logs
    • Return 200 with the original result on a repeat, never 409, or the caller's retry logic treats success as failure
    • Give the table a retention window and state that post-cleanup repeats count as new events — a stated trade-off, not a hole

    答题要点

    • 最终裁判是数据库唯一约束加 on conflict do nothing,先查后插在多实例下必然出双份
    • 键由调用方生成,但必须由事件内容决定,随机 UUID 等于没有幂等
    • 调用方给的 id 不能直接当键:加命名空间(签发方 + 用户 id + 事件 id),三段都取自验签后的令牌
    • 撞车的后果不是报错而是另一个用户静默收不到消息,日志里看不出异常
    • 重复送达返回 200 加第一次的结果,不要返回 409,否则调用方会当失败继续重试
    • 幂等表要设保留期,超期后的重复会被当成新事件,这是明确取舍不是漏洞
  • You are designing the API surface an Agent platform exposes to other services. How do you draw the responsibility boundaries?设计一组给外部服务调用的 Agent 平台接口,你会怎么划分职责边界?
    Common in ChinaCommon overseasDeep dive#api-design#security#architecture

    How to reason about it · think before answering

    1. This is an open design question testing whether you have a reusable criterion. Candidates who start listing endpoints run out of material under follow-ups; candidates who give the criterion first turn follow-ups into extra points.
    2. Offer the criterion: draw boundaries by who owns the data, not by who calls it. Sessions, run records, memories and the cost ledger belong to the platform, so the platform exposes exactly three things — write one event in (inject), read and write memory, and read usage. Orchestration belongs to the caller, so the platform should not offer run this graph for me; that pulls someone else's responsibility inside your walls and freezes both sides.
    3. Second criterion, the security invariant that runs through the whole course: identity comes from the token, never from the request body. No endpoint accepts a userId; the server always reads sub. Break this once and the authorization model collapses — a usage endpoint that accepts a userId query parameter lets any valid token enumerate everyone's spend. The same principle appeared on the memory search tool: the model gets no identity parameter, the server fills it in.
    4. Third, return the minimum necessary. Usage returns aggregates, not line items, because line items carry run ids and model choices — that hands over your internal strategy. Memory supports a query with a result limit rather than dump everything this user ever said; once that exists, some caller in a hurry will make it the default.
    5. Fourth, every write endpoint must be safely replayable: an externalId, a uniqueness constraint underneath, and 200 on a repeat. Cross-service calls will be duplicated; this is not optional.
    6. Expect: what dimension do you rate-limit on? Per user, not per caller — limiting per caller lets one user's runaway retries consume everyone's budget. Also guard against loops: tag injected messages with their source, or two services can pull each other into an infinite cycle and the bill is the only thing that tells you.

    分析过程 · 先想清楚再作答

    1. 这是开放题,考的是你有没有一条能反复用的划分依据。上来就罗列接口清单的人会被追问到没词;先给依据再给清单的人,追问反而是加分机会。
    2. 给一条判据:**按「谁拥有这份数据」划,不按「谁调用它」划。** 会话、执行记录、记忆、成本台账都属于平台,所以平台开的三个口子恰好是「写一条进来(inject)」「读写记忆(memory)」「查账(usage)」;编排逻辑属于对方,平台就不该提供「帮我跑一遍这个图」的接口——那是把对方的职责搬到自己身上,将来两边都改不动。
    3. 第二条判据是**贯穿全课的安全不变量:身份只能来自令牌,不能来自请求体**。所有接口都不接受 userId 参数,服务端一律从令牌的 sub 取。这条一旦破例,权限模型就整个塌了:查成本的接口如果接受 userId 查询参数,任何一张有效令牌都能遍历所有人的消费金额。同一条原则在 D12 的记忆检索工具上也出现过——不给模型身份参数,服务端自己填。
    4. 第三条是**返回粒度要按最小必要给**。usage 只返回汇总不返回明细,因为明细里带着执行 id 和模型选型,等于把平台的内部策略一并交出去;memory 要支持按 query 检索并限制条数,不提供「把这个人的所有记忆倒出来」的接口——一旦提供,它迟早会被某个图省事的调用方用成默认写法。
    5. 第四条是**每个写接口都要能被安全重放**:带 externalId、唯一约束兜底、重复返回 200。跨服务调用一定会重复,这不是要不要做的问题。
    6. 可以预期的追问:那限流按什么维度做?答「每用户,不是每调用方」——按调用方限流的话,一个用户的异常重试会把所有人的额度吃光;另外写接口要防回环,注入的消息要打来源标记,否则两个服务能把彼此拉进无限循环,账单是唯一会提醒你的东西。

    Key points

    • Draw boundaries by data ownership, not by caller: sessions, memory and the ledger belong to the platform, orchestration belongs to the caller
    • Three endpoints for three kinds of ownership — inject, memory, usage — and no run this graph for me endpoint that crosses the line
    • No endpoint accepts a userId; identity always comes from the token's sub, and one exception collapses the model
    • Return the minimum necessary: usage gives aggregates only, memory takes a query with a limit instead of dumping everything
    • Every write endpoint carries an externalId backed by a uniqueness constraint and answers 200 on repeats
    • Rate-limit per user rather than per caller, and tag injected messages with their source so two services cannot loop forever

    答题要点

    • 按「谁拥有这份数据」划边界,不按「谁调用」划:会话、记忆、台账属于平台,编排属于对方
    • 三个口子对应三种所有权:inject 写入、memory 读写、usage 查账;不提供「帮我跑图」这种越界接口
    • 所有接口都不接受 userId 参数,身份一律从令牌 sub 取——这条破例一次权限模型就塌了
    • 返回粒度按最小必要:usage 只给汇总不给明细,memory 按 query 限条数而不是全量倒出
    • 每个写接口都带 externalId 并由唯一约束兜底,重复返回 200
    • 限流按每用户而不是每调用方;注入的消息要打来源标记防止两个服务互相回环

Comments

Sign in to join the discussion

No comments yet — be the first.