Why Split Gateway and Worker; Postgres Table Design (sessions/runs/messages) + Drizzle
Understand why a production-grade agent splits its intake layer from its execution layer, and use Drizzle to create mini-koda's first batch of tables.
今日目标
- 能画出 Gateway / 消息总线 / Worker 三段式架构并说出各自职责
- 能用 Drizzle 定义 sessions、runs、messages 三张表并生成迁移
- 能解释无状态 Gateway 与幂等写入为什么是水平扩展的前提
昨天结尾留了一句话:那份进程内存里的会话 Map,今天会变成 Postgres 里的 sessions、runs、messages 三张表。但今天要做的不只是换个存储——先把 D7 那个「什么都自己扛」的服务从中间劈开,再决定劈开之后状态该放在哪。这是本周所有后续内容的地基。读完回来把上面三条勾掉。
小白版讲解
值机柜台不会因为一架飞机装得慢就停下来
机场的值机柜台做的事少得出奇:核对证件、称行李、发登机牌、把行李推上传送带,三十秒一位。柜员不关心你的箱子几点被装进货舱,也不关心那架飞机是不是还在等拖车。真正的重活在停机坪上,装机组把一车车行李搬进货舱,慢的时候能磨半小时。两边只靠一条行李传送带连着。
这个分工的全部价值在于一句话:柜台的处理速度不受停机坪快慢的影响。 哪怕某架飞机的装载出了大问题,队伍照样三十秒一位地往前走。
D7 那个服务恰好相反:一个进程既当柜台又当装机组。它在本机跑得很好,一上线有三种死法。
第一种,进程一重启,在途的执行就没了。D6 你已经把会话写进本地文件,解决了「聊过什么」的持久化;但那份文件里没有「正在跑的这一次执行到哪一步了」。服务重启,那次执行连尸体都找不到,用户等来的是一个永远不会来的回复。D6 解决的是历史,今天要解决的是执行本身——不是同一个问题。
第二种,两个实例各存各的。为了扛并发你起两个实例,用户第二次请求被负载均衡打到另一台,那台的 Map 是空的,Agent 一脸茫然地问「您刚才说什么来着」。
第三种最隐蔽:一个慢请求会拖垮整台机器。一次带工具调用的 Agent 循环跑三十秒并不稀奇,这三十秒里它占着连接、占着内存、占着上下文里那几万个 token 的缓冲区。一台机器同时跑十几个这样的执行,新来的健康检查请求就开始超时;编排系统判定这台机器死了,把它摘掉重启——正在跑的那十几次执行一起陪葬。
所以生产级 Agent 服务的第一条判据是:能在 Worker 做的,不要放在 Gateway。 接入层(Gateway)只做四件事:鉴权、限流、落库、投递。这四件事的共同点是耗时确定且以毫秒计,不会因为模型今天慢而变慢。
// Gateway 的 POST /chat:四件事做完就返回,绝不在这里跑 Agent 循环
app.post('/chat', async (request, reply) => {
const { sessionId, message, clientMessageId } = request.body
if (!sessionId || !message) {
return reply.code(400).send({ error: 'sessionId 和 message 必填' })
}
// 1. 鉴权、2. 限流由前置插件完成,这里只剩 3. 落库
const key = idempotencyKeyFor(sessionId, clientMessageId, message)
const run = await store.createRun({ sessionId, input: message, idempotencyKey: key })
// 4. 投递到消息总线——今天这一步是空的,D9 才把总线接上
reply.code(202) // 202 Accepted:我收下了,正在办;不是 200「办完了」
return { runId: run.id, status: run.status }
})# Gateway 的 POST /chat:四件事做完就返回,绝不在这里跑 Agent 循环
@app.post("/chat", status_code=202) # 202 Accepted:我收下了,正在办
async def chat(body: ChatRequest) -> ChatAccepted:
# FastAPI 用 pydantic 模型做入参校验,缺字段直接 422,不用自己写 if
# 1. 鉴权、2. 限流交给依赖项与中间件,这里只剩 3. 落库
key = idempotency_key_for(body.session_id, body.client_message_id, body.message)
run = await store.create_run(
session_id=body.session_id, input=body.message, idempotency_key=key
)
# 4. 投递到消息总线——今天这一步是空的,D9 才把总线接上
return ChatAccepted(run_id=run.id, status=run.status)// 依赖:spring-boot-starter-web。@Valid 让入参校验交给 Bean Validation,不用手写 if
@PostMapping("/chat")
@ResponseStatus(HttpStatus.ACCEPTED) // 202:我收下了,正在办;不是 200「办完了」
public ChatAccepted chat(@Valid @RequestBody ChatRequest body) {
// 1. 鉴权、2. 限流由 Filter 与 Spring Security 完成,这里只剩 3. 落库
var key = Runs.idempotencyKey(body.sessionId(), body.clientMessageId(), body.message());
var run = store.createRun(body.sessionId(), body.message(), key);
// 4. 投递到消息总线——今天这一步是空的,D9 才把总线接上
return new ChatAccepted(run.getId(), run.getStatus());
}// 依赖:Vapor 4。Content + Validatable 让入参校验由框架完成
app.post("chat") { req async throws -> Response in
try ChatRequest.validate(content: req)
let body = try req.content.decode(ChatRequest.self)
// 1. 鉴权、2. 限流由中间件完成,这里只剩 3. 落库
let key = idempotencyKey(for: body.sessionId, clientMessageId: body.clientMessageId,
message: body.message)
let run = try await store.createRun(sessionId: body.sessionId, input: body.message,
idempotencyKey: key)
// 4. 投递到消息总线——今天这一步是空的,D9 才把总线接上
// 202 Accepted:我收下了,正在办;不是 200「办完了」
return try await ChatAccepted(runId: run.requireID(), status: run.status)
.encodeResponse(status: .accepted, for: req)
}注意那个状态码。200 的语义是「事情办完了,结果在这里」,202 的语义是「我收下了,正在办」——拆开之后 Gateway 手里根本没有结果,只能给 202。这不是抠字眼,它决定了前端的写法:拿到 202 之后必须拿着 runId 再去订阅结果,一次请求变成「提交 + 订阅」两步。
这就是拆分的账单:你多付一次往返和一个订阅接口,换来柜台永远不排队。 值不值得,取决于你的 Agent 单次执行是三百毫秒还是三十秒。三百毫秒的场景别拆,拆了纯属自找麻烦;而只要你用了工具调用,三十秒就是常态。
无状态不是没有状态,是状态不在进程里
现在把柜台的另一半好处说清楚。你去任意一个柜台都能办登机牌,因为你的行程信息在系统里,不在某个柜员的脑子里。这就是无状态(stateless)的准确含义:不是没有状态,是状态不留在处理请求的那个进程身上。
无状态带来的直接后果有三条,条条都是水平扩展的前提:
- 加机器就等于加吞吐。新起的实例不需要「预热」或者「同步数据」,接上负载均衡立刻能干活。
- 任何一台都可以随时被杀掉。滚动发布、抢占式实例、机器故障,摘掉一台不影响任何用户,因为没有人的数据只存在于那一台上。
- 不需要会话粘连(sticky session)。D7 那份 Map 逼着你在负载均衡上配「同一个用户永远打到同一台」,而这条规则一旦存在,扩容时的重新分配就会把老用户的会话打断。
Worker 的定位正相反,它是有状态的——但要说准确:它持有的不是用户数据,而是一次执行的进度(跑到第几轮、调了哪些工具、后面 D10 还会加上一个租约)。用户数据始终在 Postgres 里,Worker 手上的只是一份正在进行时的工作。区别在于后果:Gateway 可以随便杀,Worker 被杀就必须先把手上这次执行交代清楚——这就是 D14 优雅停机要处理的事。
三段式画出来是这样:
┌────────────────┐
HTTP / SSE │ Gateway x N │ 无状态:任意一台处理任意请求
用户 ───────────▶ │ 鉴权 限流 │
│ 落库 投递 │
└───┬────────┬───┘
│ 写 │ 读
│ ▼
│ ┌──────────────────┐
│ │ Postgres │
│ │ sessions │
│ │ runs │
│ │ messages │
│ └──────────────────┘
▼ ▲
┌────────────────┐ │ 写
│ 消息总线 │ │
│ (D9 填上) │ │
└───────┬────────┘ │
│ 取 │
▼ │
┌────────────────┐ │
│ Worker x M │ ──┘ 有状态:握着一次执行的进度
│ Agent 循环 │
└────────────────┘代价照例要算:D7 读一次历史是从 Map 里取,几微秒;换成 Postgres 之后每轮至少多两次数据库往返,读历史加写消息,同机房大约各一两毫秒。慢了三个数量级,但绝对值仍然远小于一次模型调用的几百毫秒。 这笔税必须交,因为它买来的是「加机器就能扛更多人」。至于 D7 那条挂在 Gateway 上的 SSE 连接怎么和 Worker 那头的生成对上——那是 D11 的题目,今天先把状态挪出去。
三张表:长期的容器、一次的执行、不可变的事实
继续用值机的例子。系统里其实存了三份不同粒度的记录:旅客的这趟行程(长期存在,一路跟着你)、本次航班的装载作业单(这一次的作业,有开始有结束,可能失败可能重来)、行李逐件清单(每一件都是既成事实,一旦记下就不再改)。
Agent 服务的三张表就是这三样:
sessions—— 一段对话的容器,长期存在,用户的一个会话对应一条。runs—— 一次执行。用户发一句话,系统跑一轮,这一轮就是一个 run。它有生命周期:可能在排队、在跑、跑完了、失败了、被取消了。messages—— 不可变的事实。用户说了什么、Agent 回了什么,一旦写下就不再修改。
初学者最常见的做法是把 run 省掉,直接把消息挂在会话下。省不得:没有 run 这一层,你就没有任何地方能回答「这次到底跑完没有」。 重试、超时、取消、成本归集,全都需要一个「一次执行」的实体来挂靠。
create table sessions (
id text primary key,
user_id text not null,
title text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index sessions_user_id_idx on sessions (user_id);
create table runs (
id text primary key,
session_id text not null references sessions (id),
status text not null,
idempotency_key text not null unique, -- 幂等的最终裁判,见下一节
input text not null,
error text,
prompt_tokens integer not null default 0,
completion_tokens integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index runs_session_id_idx on runs (session_id);
create table messages (
id text primary key,
session_id text not null references sessions (id),
run_id text references runs (id),
role text not null,
content text not null,
seq integer not null,
created_at timestamptz not null default now(),
unique (run_id, seq) -- 保序与幂等回放的依据
);
create index messages_session_id_idx on messages (session_id);三个设计决定值得单独说:
主键是 text,不是自增整数。 因为这个 id 必须在写库之前就存在——Gateway 要立刻把 runId 放进 202 的响应体里。自增主键得等数据库把行插进去才知道值,你就只能先写后返回,白白把一次数据库往返卡在用户的等待路径上。用 UUID 或者 ULID 在应用侧生成,Gateway 一进来就能定 id,还顺带为将来分库分表留了余地。
索引只建在真正会被查的列上。 今天只有两条查询路径:按用户拉会话列表(sessions.user_id)、按会话拉历史(messages.session_id)。多余的索引不是免费的,每一个都会让写入变慢。
unique(run_id, seq) 是本周最重要的一条约束。 seq 从 0 开始、连续、不跳号,它是 D11 断线重连能从正确位置续上的唯一依据。今天你只是随手写下这行 SQL,但从 D11 起,整条保序回传链路都建立在它身上。
runs 的两件事:状态机与幂等键
一次执行不是瞬时的,它有寿命,所以要有状态。本周固定这一套:
| 状态 | 含义 | 谁写的 |
|---|---|---|
pending | 已落库,等着被取走 | Gateway |
running | Worker 取到了,正在跑 Agent 循环 | Worker |
streaming | 已经开始往外吐字 | Worker |
done | 正常结束 | Worker |
failed | 重试耗尽,彻底失败 | Worker |
cancelled | 被用户取消,或被新消息合并掉 | Gateway 或 Worker |
正常路径是 pending → running → streaming → done,异常出口是 failed 和 cancelled。注意 pending 可以直接进 failed——一条反复失败的消息可能在 run 根本没跑起来时就被判死,D9 处理毒消息时会用到这条边;把它禁掉的后果是「记录失败」这个动作本身抛非法迁移错误,那是最不该严格的地方。关键不在于有哪几个状态,而在于显式禁止非法迁移。 举个最疼的例子:一个已经 done 的 run 被某条迟到的重复消息重新推回 running,Worker 会再跑一遍并覆盖回复——用户眼睁睁看着已经收到的答案变成另一个答案。写一个几行的迁移白名单,这类事故就绝迹了。
第二件事是幂等键。至少一次(at-least-once)投递是消息总线的默认语义(D9 会讲透),加上用户手抖双击、客户端超时重发,同一句话到达 Gateway 两次是必然会发生的事,不是意外。 值机柜台的对策是按登机牌号销账:同一个人把证件递两次,系统认出这是同一段行程,不会给你两张登机牌。
幂等键就是那个「登机牌号」。它必须由请求内容决定,不能是随机数——随机 UUID 每次都不同,等于没有幂等。合理的取法是把 sessionId、客户端消息 id、消息内容拼起来做一次哈希;客户端没有消息 id 时,退而求其次用内容加上一个粗粒度时间窗。
import { createHash } from 'node:crypto'
// 幂等键必须由请求内容决定:随机数每次都不一样,等于没有幂等
export function idempotencyKeyFor(sessionId, clientMessageId, message) {
const material = clientMessageId ?? message
return createHash('sha256').update(`${sessionId}:${material}`).digest('hex')
}
// 迁移白名单:没列在这里的迁移一律拒绝
const ALLOWED = new Map([
// pending 也能直接进 failed:一条毒消息可能在 run 还没跑起来时就被判死(D9 会用到)
['pending', new Set(['running', 'cancelled', 'failed'])],
['running', new Set(['streaming', 'done', 'failed', 'cancelled'])],
['streaming', new Set(['done', 'failed', 'cancelled'])],
['done', new Set()], // 终态:done 再被推回 running 会覆盖已经给出去的回复
['failed', new Set()],
['cancelled', new Set()],
])
export function canTransition(from, to) {
return ALLOWED.get(from)?.has(to) ?? false
}import hashlib
# 幂等键必须由请求内容决定:随机数每次都不一样,等于没有幂等
def idempotency_key_for(session_id: str, client_message_id: str | None, message: str) -> str:
material = client_message_id or message
return hashlib.sha256(f"{session_id}:{material}".encode()).hexdigest()
# 迁移白名单:没列在这里的迁移一律拒绝。frozenset 表明这张表是常量
ALLOWED: dict[str, frozenset[str]] = {
# pending 也能直接进 failed:毒消息可能在 run 还没跑起来时就被判死(D9 会用到)
"pending": frozenset({"running", "cancelled", "failed"}),
"running": frozenset({"streaming", "done", "failed", "cancelled"}),
"streaming": frozenset({"done", "failed", "cancelled"}),
"done": frozenset(), # 终态:done 再被推回 running 会覆盖已经给出去的回复
"failed": frozenset(),
"cancelled": frozenset(),
}
def can_transition(source: str, target: str) -> bool:
return target in ALLOWED.get(source, frozenset())// 依赖:JDK 17+。状态用 enum 而不是 String,非法值在编译期就没机会出现
public enum RunStatus { PENDING, RUNNING, STREAMING, DONE, FAILED, CANCELLED }
public final class Runs {
// 迁移白名单用 EnumMap + EnumSet:查表是数组下标,比 HashMap 更快也更省
private static final Map<RunStatus, EnumSet<RunStatus>> ALLOWED = new EnumMap<>(Map.of(
// PENDING 也能直接进 FAILED:毒消息可能在 run 还没跑起来时就被判死(D9 会用到)
RunStatus.PENDING, EnumSet.of(RunStatus.RUNNING, RunStatus.CANCELLED, RunStatus.FAILED),
RunStatus.RUNNING, EnumSet.of(RunStatus.STREAMING, RunStatus.DONE,
RunStatus.FAILED, RunStatus.CANCELLED),
RunStatus.STREAMING, EnumSet.of(RunStatus.DONE, RunStatus.FAILED, RunStatus.CANCELLED),
// 终态:done 再被推回 running 会覆盖已经给出去的回复
RunStatus.DONE, EnumSet.noneOf(RunStatus.class),
RunStatus.FAILED, EnumSet.noneOf(RunStatus.class),
RunStatus.CANCELLED, EnumSet.noneOf(RunStatus.class)));
// 幂等键必须由请求内容决定:随机数每次都不一样,等于没有幂等
public static String idempotencyKey(String sessionId, String clientMessageId, String message) {
var material = clientMessageId != null ? clientMessageId : message;
try {
var digest = MessageDigest.getInstance("SHA-256")
.digest((sessionId + ":" + material).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest); // JDK 17 的 HexFormat,不用再手写循环
} catch (NoSuchAlgorithmException e) {
// SHA-256 是 JDK 规范强制要求提供的算法,这一支走不到;包成非受检异常,
// 免得让每一层调用方的签名都跟着带上 throws
throw new IllegalStateException(e);
}
}
public static boolean canTransition(RunStatus from, RunStatus to) {
return ALLOWED.get(from).contains(to);
}
}import CryptoKit
import Foundation
// 状态用带原始值的 enum:既能直接存进 text 列,又保证代码里不会出现拼错的状态名
// 显式写 Codable:带原始值的 enum 不会自动 conform,而下面 Fluent 的 @Field 要求它
enum RunStatus: String, Codable {
case pending, running, streaming, done, failed, cancelled
// 把迁移规则挂在枚举自己身上,比外置一张表更贴 Swift 的写法
var allowedNext: Set<RunStatus> {
switch self {
// pending 也能直接进 failed:毒消息可能在 run 还没跑起来时就被判死(D9 会用到)
case .pending: return [.running, .cancelled, .failed]
case .running: return [.streaming, .done, .failed, .cancelled]
case .streaming: return [.done, .failed, .cancelled]
// 终态:done 再被推回 running 会覆盖已经给出去的回复
case .done, .failed, .cancelled: return []
}
}
func canTransition(to target: RunStatus) -> Bool {
allowedNext.contains(target)
}
}
// 幂等键必须由请求内容决定:随机数每次都不一样,等于没有幂等
func idempotencyKey(for sessionId: String, clientMessageId: String?, message: String) -> String {
let material = clientMessageId ?? message
let digest = SHA256.hash(data: Data("\(sessionId):\(material)".utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}算出键之后,真正让幂等成立的是数据库那条唯一约束,落到 SQL 上只有一句:
insert into runs (id, session_id, status, idempotency_key, input)
values ($1, $2, 'pending', $3, $4)
on conflict (idempotency_key) do nothing
returning id;冲突时这条语句返回零行,说明有人先插进去了,回查一次拿到那条已有的 run,把它的 id 返回给用户——两次请求,一条 run,一个 runId。
Drizzle:schema 写一遍,SQL 和类型都从它派生
同一份信息被抄成好几份,改动就一定会漏。就像户口本、身份证、护照如果各存各的姓名,改名那天你要跑三个窗口,漏一个就对不上;正确做法是它们都从同一份底账派生。
建表这件事传统上要写三遍:一份建表 SQL、一份 ORM 模型、一份 TypeScript 接口。三份手写,漂移是迟早的事——某天有人加了列却忘了改类型,编译器一声不吭,运行时才炸。Drizzle 的做法是:schema 只写一遍,迁移 SQL 由它生成,查询的返回类型由它推导。
// Drizzle:schema 就是一份普通的 TS 声明,迁移和类型都从它派生
export const runs = pgTable(
'runs',
{
id: text('id').primaryKey(),
sessionId: text('session_id')
.notNull()
.references(() => sessions.id),
status: text('status').notNull().$type<RunStatus>(), // 收窄成联合类型,不是任意 string
idempotencyKey: text('idempotency_key').notNull().unique(),
input: text('input').notNull(),
error: text('error'), // 可空,所以查询结果的类型是 string | null
promptTokens: integer('prompt_tokens').notNull().default(0),
completionTokens: integer('completion_tokens').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('runs_session_id_idx').on(t.sessionId)]
)# SQLAlchemy 2.0:Mapped[...] 让类型检查器读得懂列的可空性,和 Alembic 一起生成迁移
class Run(Base):
__tablename__ = "runs"
__table_args__ = (Index("runs_session_id_idx", "session_id"),)
id: Mapped[str] = mapped_column(Text, primary_key=True)
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id"))
status: Mapped[RunStatus] = mapped_column(Text) # RunStatus 是 StrEnum,不是裸 str
idempotency_key: Mapped[str] = mapped_column(Text, unique=True)
input: Mapped[str] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text) # Optional 直接写进类型
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0)
completion_tokens: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())// 依赖:spring-boot-starter-data-jpa。约束写在注解里,Hibernate 的 schema 校验会比对真实库
@Entity
@Table(name = "runs", indexes = @Index(name = "runs_session_id_idx", columnList = "session_id"))
public class RunEntity {
@Id
private String id; // 应用侧生成 ULID,不用 @GeneratedValue:id 要在写库前就交给用户
@Column(name = "session_id", nullable = false)
private String sessionId;
@Enumerated(EnumType.STRING) // 存字符串而不是序号,加一个状态不会让老数据错位
@Column(nullable = false)
private RunStatus status;
@Column(name = "idempotency_key", nullable = false, unique = true)
private String idempotencyKey;
@Column(nullable = false, columnDefinition = "text")
private String input;
@Column private String error; // 没写 nullable = false,就是可空
@Column(name = "prompt_tokens", nullable = false)
private int promptTokens;
@Column(name = "completion_tokens", nullable = false)
private int completionTokens;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
// JPA 实体靠字段访问,但调用方要读值还是得有访问器。这里只列 Gateway 用到的两个,
// 其余同理——注意它不是 record,别写成 run.id()
public String getId() {
return id;
}
public RunStatus getStatus() {
return status;
}
}// 依赖:Vapor 的 Fluent。@OptionalField 与 @Field 在类型层面就区分了可空性
final class Run: Model, @unchecked Sendable {
static let schema = "runs"
@ID(custom: "id", generatedBy: .user) var id: String? // 应用侧生成,不交给数据库
@Parent(key: "session_id") var session: Session
@Field(key: "status") var status: RunStatus // 上面那个 enum,已 conform Codable
@Field(key: "idempotency_key") var idempotencyKey: String
@Field(key: "input") var input: String
@OptionalField(key: "error") var error: String? // 可空性写进属性包装器
@Field(key: "prompt_tokens") var promptTokens: Int
@Field(key: "completion_tokens") var completionTokens: Int
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
// Fluent 的 @ID 与 @Timestamp 一定是 Optional(写库前 id 可能还没有、
// created_at 由数据库填)。但从库里读出来的行这两个必然有值,
// 所以在模型上收口成 requireXxx(),别让业务代码到处 ?? ——
// 这是四门语言里只有 Swift 会撞上的一处代价
func requireID() throws -> String {
guard let id else { throw FluentError.idRequired }
return id
}
func requireCreatedAt() throws -> Date {
guard let createdAt else { throw FluentError.missingField(name: "created_at") }
return createdAt
}
}四份代码长得很不一样,做的却是同一件事:把表结构声明成宿主语言里的类型,让唯一约束、可空性、外键这些信息只存在一处。 之后查询的返回值就自带类型,你写错列名是编译错误而不是线上报错。
声明完就是生成迁移。Drizzle 的一条龙是两条命令:
pnpm drizzle-kit generate # 比对 schema 与已有迁移,产出带序号的 SQL 文件
pnpm drizzle-kit migrate # 按序号执行,并把执行记录写进 __drizzle_migrations 表关键在于第一条产出的是文件,要进版本库、要过 code review。很多人会用「直接把数据库同步成 schema 的样子」那种模式(Drizzle 里叫 push),它在本地玩玩可以,生产上是灾难:没有可回放的历史、没有 review 的时机、也说不清线上那台库到底停在哪一版。迁移是一条有序的链,不是一次快照。
让 Postgres 跑起来:compose 与「真的能用了」
最后一步是把库跑起来。这里有个所有人都踩过的坑:容器「启动了」不等于「能用了」。 就像航站楼推上电闸不代表行李系统在转,Postgres 的进程起来之后还要几秒钟做初始化,这几秒里连上去只会得到一个拒绝连接。depends_on 默认只等容器启动,不等它就绪,于是 Gateway 抢跑、第一条查询报错、进程退出——本机跑十次挂三次,CI 里更是随机红。
解法是健康检查加条件依赖:
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: koda
POSTGRES_PASSWORD: koda
POSTGRES_DB: koda
ports: ['5508:5432']
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U koda -d koda']
interval: 2s
timeout: 3s
retries: 15
volumes: [koda-pg:/var/lib/postgresql/data]
gateway:
build: .
environment:
DATABASE_URL: postgres://koda:koda@postgres:5432/koda
depends_on:
postgres:
condition: service_healthy # 等的是「能用了」,不是「起来了」
ports: ['3000:3000']
volumes:
koda-pg:镜像选的是 pgvector/pgvector:pg16 而不是官方的 postgres:16——它就是官方镜像加装了向量扩展,现在多花零成本,省得 D12 做长期记忆时再换一次镜像、重灌一次数据。能预见的依赖,早一天装上比晚一天迁移便宜得多。
Gateway 自己也要暴露健康检查,而且要分清两种:存活探针只回答「进程还在吗」,就绪探针回答「现在可以给我发流量吗」。区别在故障时才显现——数据库抖了一下,就绪探针转红让这台暂时不接流量,等恢复了自动回来;如果这时用的是存活探针,编排系统会把进程直接杀掉重启,反而更慢。
源码导读
动手实验
这个实验是本周的地基,后面六天都在它上面长。目录按 gateway / worker / shared / infra 四层切开——正文说「生产里这是三个独立的包」,实验里用目录表达同一件事,这样每个 lab 仍然是一个能独立安装的工程。MOCK=1 下用的是内存版存储,它不是打桩,而是把唯一约束、复合唯一约束、状态机这些语义用 Map 和数组真实实现了一遍——所以不装 Docker 也能看到幂等真的生效。想跑真库就 docker compose up -d 再设置 DATABASE_URL,跑的是同一份业务代码,只有 src/infra/ 里换了实现。
- 按 gateway / worker / shared / infra 四层建目录,先原样跑一次
MOCK=1 SELFTEST=1 pnpm start,看清第 3、4、5 项的 ❌ 长什么样。 - 用 docker compose 起 Postgres(镜像 pgvector/pgvector:pg16,带 pg_isready 健康检查),把 DATABASE_URL 写进 .env。
- 在 shared 里用 Drizzle 定义三张表,跑 drizzle-kit generate 产出迁移文件,再 migrate 到刚起的库里,去库里确认两个唯一约束真的建上了。
- 补齐 idempotencyKeyFor 与内存版的唯一约束检查,重跑自检:第 3、5 项变 ✅,同一条消息发两次只产生一个 runId。
- 补齐状态机白名单让第 4 项变 ✅,然后跑一遍 seed 脚本,用 worker 侧的只读查询确认它看到了同一条 pending run——执行留给明天。
面试题
今天 4 道题在下方题库区,侧重单体与拆分的取舍、无状态服务、幂等写入、三张表的主键与索引设计。展开后先看「分析过程」再看要点——第 3 题的追问(先查后插为什么不是幂等)是本章最容易被追到底的地方,别跳过。
检查清单与明日预告
- 能画出 Gateway / 消息总线 / Worker 三段式架构并说出各自职责
- 能用 Drizzle 定义 sessions、runs、messages 三张表并生成迁移
- 能解释无状态 Gateway 与幂等写入为什么是水平扩展的前提
- 能说清 run 的六个状态各由谁写入,以及为什么必须显式禁止非法迁移
- 实验的 5 条验收标准全部通过,六项自检全绿
- 4 道面试题不看要点也能答出至少 3 道
明天(D9)我们把三段式中间那个还空着的方框填上:用 Redis Streams 做消息总线,让 Gateway 落完库把 run 投递进流,再写一个 Worker 把它捞出来真正执行、回写 messages 表。为什么今天不一起做完?因为「状态放在哪」和「消息怎么传」是两个独立的决定,混在一起学,出问题时你分不清是表设计错了还是投递语义错了。今天定下来的 idempotency_key 明天会立刻派上用场——消息总线是至少一次投递,重复消费全靠它挡。
Interview questions
Why do production agent services usually split a gateway from workers, and when should you not split?为什么生产级 Agent 服务通常要把 Gateway 和 Worker 拆开?什么情况下不该拆?
Common in ChinaCommon overseasBasic#architecture#scalabilityHow to reason about it · think before answering
- The hinge is the second half. Answering only 'decoupling and scalability' sounds copied from a textbook; the interviewer wants to know which concrete symptom forced you to split, and what splitting costs.
- Offer a reusable chain: one agent run is long and unpredictable (model latency plus several tool calls, seconds to tens of seconds), while the ingress path carries all traffic and must stay in the millisecond range. Put workloads three orders of magnitude apart in the same process and the slow one starves the fast one.
- Make the symptom concrete: a single process running a dozen long executions saturates connections and memory, health checks start timing out, the orchestrator declares the instance dead and restarts it, and every in-flight run dies with it. That story lands harder than any abstract argument.
- Then state the rule: anything a worker can do should not live in the gateway, which keeps only auth, rate limiting, persistence and dispatch — four steps with bounded latency. After the split the stateless gateway scales with traffic while worker concurrency is tuned against model quota; the two curves were never the same.
- Volunteer the cost, which is where candidates separate: the contract becomes 202 instead of 200 so clients need a second subscribe round trip, you now operate a bus and a runs table, tracing spans more hops, and local development needs more processes. So do not split when a run takes a few hundred milliseconds, uses no tools, and serves modest traffic.
- Expect the follow-up: could a thread pool or child processes do instead? They ease starvation but fix neither 'restart loses in-flight work' nor 'two instances cannot see each other's state', because the root cause is state living inside the process, not the concurrency model.
分析过程 · 先想清楚再作答
- 题眼在后半句。只答「解耦、可扩展」是从架构书上抄来的,面试官想知道你有没有被某个具体现象逼着拆过——所以答案里必须出现「什么现象」和「不拆的代价」。
- 先给一条可复用的推导链:Agent 的一次执行是长耗时且时长不可预测的(模型响应加上多轮工具调用,几秒到几十秒),而接入层要承载全部流量、必须是毫秒级的短请求;把两种时长量级差三个数量级的工作放进同一个进程,慢的那一类必然会挤占快的那一类的资源。
- 把现象说具体:单进程时一台机器同时跑十几次长执行,连接与内存被占满,新来的健康检查开始超时,编排系统判定实例已死并重启它——正在跑的执行全部陪葬。这个「健康检查被自己的业务拖挂」的故事比任何抽象论证都有说服力。
- 然后给判据:能在 Worker 做的不放 Gateway,接入层只留鉴权、限流、落库、投递这四件耗时确定的事。拆开之后 Gateway 无状态可以任意扩缩,Worker 的并发度可以按模型配额单独调,两者的扩容曲线本来就不一样。
- 主动说代价,这是区分度所在:接口语义从 200 变成 202,客户端要多一次订阅往返;系统里多了一条总线和一张 runs 表,可观测性和排障链路都变长;本地开发要起更多进程。所以单次执行只有几百毫秒、没有工具调用、日活很小的场景不该拆——那时候拆分带来的复杂度远大于收益。
- 可以预期的追问:不拆但用线程池或者子进程行不行?答案是能缓解「挤占」但解决不了「重启即丢失」和「多实例状态不共享」,因为那两件事的根因是状态在进程里,不是并发模型不对。
Key points
- A run takes seconds to tens of seconds while ingress requests are millisecond-scale; in one process the long work starves the short work
- Three concrete failure modes: restarts lose in-flight runs, multiple instances hold separate state, and long runs stall health checks so the orchestrator kills a healthy instance
- The rule is that anything a worker can do stays out of the gateway, which keeps only auth, rate limiting, persistence and dispatch
- After splitting, gateways scale on traffic and workers scale on model quota — two independent curves
- Costs: a 202 contract plus a subscribe round trip, an extra bus and table to operate, longer traces; skip the split for sub-second runs with no tool calls
答题要点
- 一次 Agent 执行是几秒到几十秒的长任务,接入层是毫秒级短请求,两者同进程时长任务必然挤占短请求的资源
- 单进程的三个具体死法:重启丢掉在途执行、多实例状态各存各的、长执行把健康检查拖超时导致实例被误杀
- 判据是「能在 Worker 做的不放 Gateway」,接入层只留鉴权、限流、落库、投递
- 拆开后 Gateway 无状态按流量扩容、Worker 按模型配额扩容,两条曲线可以独立调
- 代价是接口从 200 变 202、多一次订阅往返、排障链路变长;单次执行仅几百毫秒且无工具调用的场景不该拆
What makes a service stateless, what does that mean for horizontal scaling, and are workers stateful?什么是无状态服务?它对水平扩展意味着什么?Worker 算不算有状态?
Common in ChinaCommon overseasIntermediate#stateless#scalabilityHow to reason about it · think before answering
- The trap is reading the word literally. Many candidates say 'it stores nothing', which is wrong — stateless services write to databases all day. The discriminator is whether you can define it precisely.
- One sentence does it: stateless means state does not live in the process handling the request, so any instance can serve any request. Turn it into a self-check: kill a random instance — does any user's data exist only there? Only 'no' is stateless.
- Derive three scaling consequences: a new instance needs no warm-up or data sync and starts serving the moment it joins the load balancer; any instance can be killed at will, which is what makes rolling deploys and spot instances viable; and no sticky sessions are needed, whereas stickiness means rebalancing during a scale-up cuts existing conversations.
- Answer the worker half carefully: it holds execution progress, not user data — which turn it is on, which tools it called, and later a lease. User data always lives in the database. So 'stateful' here means 'holding unfinished work', and the consequence is that you cannot kill it freely: drain first, refuse new work, let the current run finish.
- Expect the follow-up: does an in-memory cache break statelessness? It depends on whether losing it causes wrong behavior. A pure accelerator that only costs latency is fine; the moment a user's session exists only in one machine's memory you are silently relying on stickiness, and the next scale-up will prove it.
分析过程 · 先想清楚再作答
- 这题的陷阱是字面理解。很多人答成「不保存任何数据」,那是错的——无状态服务当然会写数据库。区分度在于你能不能给出准确定义。
- 准确定义只有一句:无状态指的是**状态不留在处理请求的那个进程身上**,因此任意一台实例都能处理任意一个请求。把它翻译成一个自检问题就很好用:随便杀掉一台实例,有没有任何用户的数据只存在于那台机器上?答「没有」才是无状态。
- 再推出水平扩展的三个后果:新实例不需要预热或同步数据,接上负载均衡立刻能干活;任意实例可以随时被杀,滚动发布和抢占式实例才成立;不需要会话粘连,而粘连一旦存在,扩容时的重新分配就会打断老用户的会话。
- Worker 那一问要答得有分寸:它持有的不是用户数据,而是一次执行的进度(跑到第几轮、调了哪些工具、后面还会加上一个租约)。用户数据始终在数据库里。所以说它有状态,指的是「手上有活没交代完」,后果是不能随便杀——必须优雅停机,先拒绝新任务再等手头的跑完。
- 可以预期的追问:内存缓存算不算破坏了无状态?答案是看丢了会不会出错。纯粹用于加速、丢了只是变慢的缓存不破坏无状态;一旦某个用户的会话只存在于某台机器的内存里,你就已经在偷偷依赖粘连了,扩容那天必然出事。
Key points
- Stateless means the state does not live in the request-handling process, so any instance serves any request — not that nothing is stored
- Self-check: kill any instance and ask whether any user's data existed only there
- Three scaling prerequisites: no warm-up, any instance disposable, no sticky sessions
- Workers are stateful in the sense of holding run progress, not user data, so they need graceful drain rather than a hard kill
- A pure accelerator cache is fine; in-memory data that is the only copy is implicit stickiness
答题要点
- 无状态的准确含义是状态不留在处理请求的进程里,任意实例都能处理任意请求,而不是「不存数据」
- 自检方法:随便杀一台实例,是否有用户的数据只存在于那一台上
- 水平扩展的三个前提:新实例无需预热、任意实例可被随时杀掉、不需要会话粘连
- Worker 的有状态指的是持有一次执行的进度而不是用户数据,后果是必须优雅停机而不能随便杀
- 只加速、丢失只降速的缓存不破坏无状态;承载唯一副本的内存数据等于隐式的会话粘连
With at-least-once delivery, how do you guarantee a redelivered message does not create two runs?消息总线是至少一次投递,同一条消息被重复投递时,怎么保证不会产生两条 run?
Common in ChinaCommon overseasDeep dive#idempotency#database#reliabilityHow to reason about it · think before answering
- This question is about which layer idempotency lives in. Anyone who answers 'check whether it exists, then insert' has usually just failed it — that is exactly the answer being screened out.
- State the premise: duplicates are not accidents. The bus is at-least-once, clients retry on timeout, users double-click. The same message arriving twice is certain, so the goal is not to prevent duplicates but to make duplicates produce the same result.
- Then derive the key: idempotency needs a key derived from request content. A random UUID differs every time and buys nothing; hash the session id, the client message id and the message body together, falling back to content plus a coarse time bucket when the client has no id.
- Land it in storage: put a unique constraint on that column in the runs table, write the insert as on-conflict-do-nothing, and when it returns zero rows read back the existing run and return the same run id. Two requests, one run, one id.
- Explain why check-then-insert fails, which is the whole point: two gateway instances can query, both see nothing, and both insert. The window between the two statements cannot be closed in application code, it is too narrow to reproduce under load tests, and it leaks a few bad rows every day in production. The database's unique constraint has to be the final arbiter; the application-level check only saves a wasted insert.
- Expect the follow-up: what about duplicate execution on the consumer side? The unique constraint gives you one run, but a worker can still receive it twice, so status changes need conditional updates (move to running only if the current status is pending) plus an explicit transition whitelist that blocks a finished run from being pushed back to running and overwriting a reply the user already saw.
分析过程 · 先想清楚再作答
- 这题在考幂等的落点在哪一层。凡是答「在代码里先查一下有没有,没有再插入」的,基本当场结束——因为那正是这题想筛掉的答案。
- 先把前提摊开:重复不是意外。总线是至少一次语义、客户端会超时重发、用户会手抖双击,同一句话到达两次是必然事件。所以设计目标不是「避免重复到达」,而是「重复到达时结果相同」。
- 然后给推导:幂等需要一个由请求内容决定的键。随机 UUID 每次都不同,等于没有幂等;正确取法是把会话 id、客户端消息 id、消息内容拼起来做哈希,客户端没有消息 id 时退用内容加一个粗粒度时间窗。
- 结论落在存储层:在 runs 表的这一列上加唯一约束,插入写成「冲突就什么都不做」,返回零行时回查那条已有的 run,把同一个 runId 返回给用户。两次请求、一条 run、一个 runId。
- 解释为什么「先查后插」不行,这是本题的分水岭:两个 Gateway 实例可以同时查、同时发现没有、同时插入,这两步之间有一个应用层拦不住的时间窗;它窄到压测复现不出来,上线后每天漏几条。**幂等的最终裁判必须是数据库的唯一约束**,应用层的判断只是为了少一次插入尝试。
- 可以预期的追问:那消费侧的重复执行呢?答:唯一约束保证了只有一条 run,但 Worker 可能重复拿到同一条 run,所以状态迁移也要带条件更新(只有当前状态是 pending 时才能改成 running),并且用一个显式的迁移白名单挡住「已完成的 run 被推回运行中」这种会覆盖用户已收到回复的情况。
Key points
- Redelivery is certain, so the goal is identical outcomes on duplicates, not preventing duplicates
- The idempotency key must be derived from request content — session id plus client message id plus body, hashed; a random UUID buys nothing
- Put a unique constraint on that column, insert with on-conflict-do-nothing, and read back the existing run when zero rows return
- Check-then-insert races under concurrency; the window between the statements cannot be closed in application code, so the unique constraint must be the final arbiter
- On the consumer side add conditional status updates and a transition whitelist so a finished run is never re-run or overwritten
答题要点
- 重复投递是必然事件,设计目标是「重复到达时结果相同」,不是「避免重复」
- 幂等键必须由请求内容决定:会话 id 加客户端消息 id 加内容做哈希,随机 UUID 等于没有幂等
- 在 runs 的幂等键列上建唯一约束,插入用「冲突就什么都不做」,零行时回查已有 run 返回同一个 runId
- 先查后插在并发下必然出双份,两条语句之间的时间窗应用层拦不住,幂等的最终裁判是数据库唯一约束
- 消费侧还要用条件更新加状态迁移白名单,避免同一条 run 被重复执行或把已完成的回复覆盖掉
How would you design primary keys and indexes for sessions, runs and messages, and why avoid auto-increment ids?sessions / runs / messages 这三张表你会怎么设计主键与索引?为什么不用自增主键?
Common in ChinaCommon overseasIntermediate#database#schema-design#idempotencyHow to reason about it · think before answering
- It looks like a trivia question, but every choice sits on a concrete constraint. The test is whether you can say what breaks if you choose otherwise.
- Start with why three tables rather than one: the grains differ. A session is a long-lived container, a run has a lifecycle and can fail and be retried, a message is an immutable fact. Without the run layer there is nowhere to answer 'did this finish', 'should we retry', or 'what did this turn cost'.
- Use text primary keys generated in the application (UUID or ULID), because the gateway must put the id into the 202 response before the row is written. Auto-increment ids are only known after the insert, which parks a round trip in the user's wait path and cannot be pre-allocated across instances. A bonus is that sharding later needs no renumbering.
- Index by query path, not by instinct: sessions need an index on user_id to list a user's conversations, messages need one on session_id to load history, and foreign key columns need indexes or deleting a parent row triggers a full scan. Extra indexes are not free — each one slows writes.
- The two unique constraints carry the design: a unique idempotency key on runs blocks duplicate delivery, and a composite unique on run id plus sequence in messages both fixes output ordering for one run and lets a reconnect replay idempotently by sequence. The sequence must start at zero and never skip, otherwise resume cannot find the cut point.
- Expect the follow-up: ULID or UUIDv4? Choose ULID or UUIDv7 — they are time-ordered so inserts land at the right edge of the B-tree, whereas random UUIDv4 scatters writes, splits pages and hurts cache hit rates. Mentioning this shows you have watched write performance.
分析过程 · 先想清楚再作答
- 这题看着像八股,其实每一个选择背后都有一个具体约束。判断标准是:你能不能为每个决定说出「不这么做会发生什么」。
- 先讲为什么是三张表而不是一张:粒度不同。会话是长期容器,一次执行有生命周期且可能失败重来,消息是不可变事实。少了「一次执行」这一层,你就没有地方回答「这次跑完没有」「该不该重试」「这轮花了多少钱」。
- 主键选文本型的应用侧 id(UUID 或 ULID),理由是接入层必须在写库之前就把 id 放进 202 响应体返回给客户端;自增主键要等数据库插完才知道值,那次往返就被卡在用户的等待路径上,而且多实例无法预分配。附带好处是将来分库分表不用重编号。
- 索引按查询路径建,不按直觉建:按用户拉会话列表要 sessions 的 user_id 索引,按会话拉历史要 messages 的 session_id 索引,外键列本身要索引否则删除父行会全表扫。多余的索引不是免费的,每个都让写入变慢。
- 两条唯一约束才是这套设计的灵魂:runs 的幂等键唯一,挡住重复投递;messages 的「run id 加序号」复合唯一,既保证同一次执行的输出顺序稳定,又让断线重连可以按序号幂等回放。序号要从 0 开始、连续、不跳号,否则续传就找不到断点。
- 可以预期的追问:ULID 和 UUIDv4 选哪个?答 ULID 或 UUIDv7——它们按时间有序,插入时集中在 B 树右端,不像 UUIDv4 那样随机分布导致页分裂和缓存命中率下降。这个细节能直接体现你关心过写入性能。
Key points
- Three tables for three grains: a long-lived session, a run with a lifecycle, and immutable messages; without runs you cannot answer completion, retry or cost questions
- Application-generated text ids, because the gateway must return the run id in the 202 before the write, and auto-increment ids cannot be pre-allocated across instances
- Index the real query paths — user_id on sessions, session_id on messages, plus foreign key columns; extra indexes slow writes
- Two unique constraints carry the design: a unique idempotency key on runs, and a composite unique on run id plus sequence in messages for ordering and idempotent replay
- Prefer time-ordered ids such as ULID or UUIDv7 over random UUIDv4 to avoid page splits and cache misses
答题要点
- 三张表对应三种粒度:会话是长期容器、run 是一次有生命周期的执行、message 是不可变事实;少了 run 就无法回答是否跑完、该不该重试、花了多少钱
- 主键用应用侧生成的文本 id,因为 Gateway 要在写库之前把 runId 放进 202 响应里,自增主键必须等插入完成且无法跨实例预分配
- 索引按实际查询路径建:sessions 的 user_id、messages 的 session_id、以及外键列;多余索引会拖慢写入
- 两条唯一约束是灵魂:runs 的幂等键唯一挡重复投递,messages 的「run id 加序号」复合唯一保证保序与幂等回放
- id 优先选 ULID 或 UUIDv7 这类时间有序的方案,避免随机 UUID 造成的页分裂与缓存失效
Comments
Sign in to join the discussion
No comments yet — be the first.