我是如何独自构建10xInterview.com的——双后端AI路由器、SSE令牌流、用于嵌入的pgvector,以及Webhook驱动的计费。一个Google Interview Warmup的替代品。
当Google悄然退役了Interview Warmup这个虽小但有用的工具(它让你录制面试口语回答并获得AI反馈)时,我有两种反应。第一:太可惜了,我用过它。第二:我能做出更好的。
一年后,10xInterview(10xInterview交互面试) 已上线。单人构建。一个 Go 二进制文件,一个 React 单页应用,一个 Postgres 数据库,一个 Shell 脚本即可部署整个项目。
这篇文章是工程实战分享——四项架构决策,使得代码库在持续交付功能一年后依然显得小巧。如果你是独立创始人,正在构建 AI 密集型产品,以下模式是我会再次采用的。
TL;DR(太长了;) 无聊的栈,固执的路由,流式一切,单一数据库,webhooks作为事实来源。内联代码示例。
栈(故意乏味)
Backend Go 1.23, Chi router, single binary
Database Postgres 17 + pgvector
Frontend React 19, Vite, TanStack Query, shadcn/ui
AI Vertex AI (free tier) + Gemini API (Pro)
Speech Google STT + TTS
Infra Cloud Run x2, Cloud SQL, HTTPS LB, Secret Manager
Billing Razorpay Subscriptions (webhook-driven)
Deploy One idempotent shell script
没有微服务。没有事件总线。没有Kafka。没有Redis(目前)。没有用于向量的第二数据库。没有SSR。没有全局客户端存储。没有月度框架。
约束条件“必须有人在凌晨3点也能操作这个”这个原则决定了每一个选择。
决策一:双后端AI路由器(dual-backend AI Router)
问题:免费用户会瞬间耗尽昂贵的LLM API(大型语言模型API)配额。但专业用户(Pro用户)期待有意义的更好体验。如何从同一段处理代码中同时服务这两类用户?
解决方案:每个AI能力都是一个接口,对应两个实现。一Router 结构体在请求时根据上下文中的计划进行选择。
// services/agent/router.go
type Reviewer interface {
Review(ctx context.Context, in ReviewInput) (ReviewOutput, error)
}
type ReviewerRouter struct {
Free Reviewer // Vertex AI, Gemini 2.5 Flash
Paid Reviewer // Gemini API, stronger model
}
func (r *ReviewerRouter) Review(ctx context.Context, in ReviewInput) (ReviewOutput, error) {
if auth.PlanFromContext(ctx) == auth.PlanPro {
return r.Paid.Review(ctx, in)
}
return r.Free.Review(ctx, in)
}
处理程序从不检查计划。它们永远不知道命中哪个后端。身份验证中间件将计划置于上下文中;路由器执行正确操作。
这种模式带来了三个好处:
-
默认优雅降级。 如果
GEMINI_API_KEY未设置,付费实现是nil,路由器会回退到免费版。Pro 用户会静默地获得更便宜的型号,而不是 500。轮换的密钥不会导致网站宕机。 -
使用零凭据进行本地开发。 如果
AGENT_ENABLED=false,每个代理都会成为返回固定数据的确定性桩。新贡献者克隆仓库,go run ./cmd/server,就能拥有一个无需接触 Google Cloud 即可运行的应用。 -
添加第三层是一个结构字段。当(如果)我添加一个不同模型的企业级(Enterprise tier)时,它是
r.Enterprise = ...以及调度中的一个额外分支。无需更改处理器。 我现在大约有6个这样的路由器(router)——审查器(reviewer)、生成器(generator)、设计器(designer)、实时面试官(live interviewer)、解释器(explainer)、简历解析器(resume parser)。这种模式从第二个路由器(Router #2)开始就收回了成本。
决策2: SSE令牌流(SSE token streaming)通过一个小型进程内代理(tiny in-process broker)
问题: 答案审核的第一个版本是一个同步的 REST 调用。上传音频 → 等待 8–14 秒 → 渲染 JSON 响应。它能工作,但体验也很糟糕。
解决方案: 通过 Server-Sent Events 逐 token 流式输出 LLM 的结果。前端在生成过程中实时渲染评分和反馈。
架构有意保持最小化:
// Broker.go — ~150 lines total
type Broker struct {
mu sync.RWMutex
subs map[string][]chan Event // submissionID -> subscribers
}
func (b *Broker) Subscribe(id string) (<-chan Event, func()) {
ch := make(chan Event, 32)
b.mu.Lock()
b.subs[id] = append(b.subs[id], ch)
b.mu.Unlock()
return ch, func() { /* unsubscribe + close */ }
}
func (b *Broker) Publish(id string, e Event) {
b.mu.RLock()
for _, ch := range b.subs[id] {
select {
case ch <- e:
default: // drop if subscriber is slow
}
}
b.mu.RUnlock()
}
HTTP 处理器:
func (h *Handler) StreamSubmission(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher := w.(http.Flusher)
events, cancel := h.broker.Subscribe(submissionID)
defer cancel()
for {
select {
case e := <-events:
fmt.Fprintf(w, "data: %s\n\n", e.JSON())
flusher.Flush()
if e.Type == "complete" { return }
case <-r.Context().Done():
return
}
}
}
前端打开一个 EventSource:
const es = new EventSource(`/api/v1/submissions/${id}/stream`);
es.onmessage = (e) => {
const event = JSON.parse(e.data);
setReview((prev) => mergeEvent(prev, event));
};
总的挂钟时间与同步版本相同。感知时间大约是一半。用户从第30个令牌开始阅读反馈,而不是等待第400个令牌。
本次用户体验升级的成本:大约200行Go代码,约40行TypeScript代码,零新增基础设施。不需要Redis、NATS或Kafka。单体应用内部的发布/订阅不需要消息队列。
注意: 这种模式之所以有效,是因为后端在会话期间是单个 Cloud Run 实例——提交和订阅者存在于同一个进程中。一旦我需要为每个用户会话扩展到多个实例,我会要么将代理换成 Redis pub/sub,要么使用粘性 Cookie 固定会话。但这不是今天的问题。
决策3:使用 Postgres + pgvector 而不是向量数据库
问题: 两个功能需要向量相似性:
- 问题去重 — 当管理员或AI批量生成新问题时,14种不同措辞的"什么是闭包?"不应全部进入目录。
- 简历感知推荐 — 给定上传的简历的嵌入表示,显示与候选人所述技能最相似的问题。 差点犯的错误: 我几乎要使用Pinecone。
实际解决方案: CREATE EXTENSION vector; 的 questions 表上增加了一个额外列,resumes 上增加了一个额外列,完成。
-- migrations/0007_add_embeddings.sql
CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE questions
ADD COLUMN embedding vector(768);
CREATE INDEX questions_embedding_idx
ON questions
USING hnsw (embedding vector_cosine_ops);
插入前的去重检查:
SELECT id, title, 1 - (embedding <=> $1) AS similarity
FROM questions
WHERE topic_id = $2
ORDER BY embedding <=> $1
LIMIT 1;
-- reject if similarity > 0.92
推荐查询:
SELECT q.id, q.title, q.topic_id
FROM questions q
JOIN topics t ON t.id = q.topic_id
WHERE t.id = ANY($2) -- relevant topics from resume
ORDER BY q.embedding <=> $1 -- resume embedding
LIMIT 20;
一个数据库。一个备份。一个需要监控的对象。一个连接池。
反对pgvector的论点通常是“它无法扩展到N个向量以上”。对于10x面试(10xInterview)的工作负载——目前约有5万个问题嵌入,增长缓慢——这个上限在几年内还不会达到。当它不再是最佳选择时,将其替换为一个文件中的局部重构。可选择性得以保留。
如果你的向量数量在100万以下,并且正在考虑使用独立的向量数据库:请先试试pgvector。你可能永远不需要迁移。
决策4:Webhooks是计费的单一事实来源
问题:计费错误是最糟糕的错误。用户付款了,系统却没有察觉,支持工单堆积如山。信任荡然无存。
规则:结账端点从不 将用户标记为 Pro。只有 Webhooks 能做到。
POST /api/v1/billing/checkout → Mints Razorpay subscription, returns IDs.
Does NOT change user.plan.
POST /webhooks/razorpay → Razorpay-initiated. HMAC verified.
Inserts to payment_events.
Updates user.plan ONLY if insert succeeded.
完整的 Webhook 处理程序:
func (h *Handler) RazorpayWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Razorpay-Signature")
if !verifyHMAC(body, sig, h.cfg.RazorpayWebhookSecret) {
http.Error(w, "bad signature", 401)
return
}
var evt RazorpayEvent
if err := json.Unmarshal(body, &evt); err != nil {
http.Error(w, "bad json", 400); return
}
// The idempotency key
err := h.db.InsertPaymentEvent(r.Context(), PaymentEvent{
ProviderEventID: evt.ID, // UNIQUE constraint
Type: evt.Event,
Payload: body,
})
if errors.Is(err, ErrDuplicate) {
w.WriteHeader(200) // already processed; no-op
return
}
if err != nil {
http.Error(w, "db error", 500); return
}
switch evt.Event {
case "subscription.activated", "subscription.charged":
h.db.UpgradeUser(r.Context(), evt.Subscription.UserID, "pro", evt.Subscription.EndAt)
case "subscription.cancelled", "subscription.halted":
// Don't downgrade immediately. Let it expire naturally.
h.db.MarkCancelled(r.Context(), evt.Subscription.UserID)
}
w.WriteHeader(200)
}
这为您提供四个属性:
-
幂等性由构造保证。 Razorpay 可以重试同一 Webhook 最多 10 次。唯一约束 on
provider_event_id意味着第一个胜出,其余无操作。无需应用程序级别的去重逻辑。 - 无需Cookie认证的端点。 Webhook路由挂载在根目录,位于 Cookie中间件之外。Razorpay不携带Cookie,攻击者也无法伪造——HMAC就是认证。
-
免费审计追踪。
每笔收到的支付事件都在
payment_events。争议、退款、“为什么扣费”工单——一条 SQL 查询即可回答所有问题。 -
无需夜间定时任务(cron)。到期降级由认证中间件(auth middleware)处理:当 Pro 用户的
pro_until在收到请求时已过期,中间件会在请求处理过程中即时降级其权限。状态始终正确,因为状态总是主动校验(checked),而非被动扫描(swept)。一个操作提示: Razorpay 订阅计划仅支持 INR,没有支持工单流程来启用多币种。因此,定价页面针对非印度访客(通过浏览器时区检测)显示 USD 仅为展示性换算,实际收取 INR。小细节。节省了大量困惑的支持工单。
部署历程
PROJECT_ID=my-prj DOMAIN=10xinterview.com ADMIN_EMAILS=me@x.com \
GOOGLE_CLIENT_ID=… GOOGLE_CLIENT_SECRET=… \
./deploy.sh
这是一个全新的 GCP 项目,在 约 12 分钟内即可进行实时部署。deploy.sh提供:
- 启用 pgvector 的 Cloud SQL 实例
- 两个 Cloud Run 服务(api + web)
- 使用 Google 托管证书的 HTTPS 负载均衡器
- 用于嵌入回填 (Embeddings backfill) 的 Cloud Run 作业
- 每个凭证的 Secret Manager 条目
每个步骤使用
describe-or-create. 重新运行是安全的。脚本中途失败只需再次运行即可恢复。
Razorpay和Gemini API密钥已挂载只有在它们的秘密存在之后——首次部署时没有它们是可以的。AI功能降级为免费层行为;计费端点返回503 Service Unavailable直到你添加密钥。
幂等部署 + 优雅降级 = 一个你可以安心睡一整晚的单人SaaS服务。
我会做出不同的选择
一年后,如果重新开始,我会改变三件事:
-
更早采用sqlc。 我从手写代码开始,
database/sql大约在第四个月迁移到sqlc。如果一开始就使用sqlc,代码库会更干净。 - 在两个层级上使用相同的嵌入模型。我曾短暂尝试为Pro用户使用更强的嵌入模型。召回率的差异不值得成本,而且双嵌入的记账工作简直是噩梦。现在两个层级使用相同的Vertex嵌入模型。
-
跳过设计系统实验。我尝试了Tailwind UI,然后是Park UI,最后是shadcn/ui。本应从shadcn/ui开始的。浪费了两个周末。
如果你曾经使用过谷歌的面试热身(Interview Warmup)
顺便说一下,因为我在Reddit上经常看到这个问题。
谷歌退役了面试热身(Interview Warmup)——这个来自与谷歌一起成长(Grow with Google)的实验性工具,让你录制答案并获得人工智能反馈。很多人喜欢它。它已经没有了。
10倍面试(10xInterview)不是克隆。映射如下:
- Warmup所做的事情: 记录口头回答 → 获得对提及的见解、词汇和要点的分析。
- 10倍面试(10xInterview)重叠的部分: 记录口头回答 → 获得0–100分的评分和具体建议,实时流式传输。
- 10倍面试(10xInterview)新增的功能:按主题和技能策划的问题库,包含汇总最终报告的模拟面试,一个能够进行自适应追问的实时AI面试官,基于简历的问题推荐,以及按需生成带有Mermaid图表(Mermaid diagrams)的解释。
- 目前尚未实现的功能:STAR格式的行为评分(已列入近期路线图)。与Google无关联,只是填补了他们留下的空白。
免费套餐涵盖了核心的“录制并获取评分”循环,但每周有使用限制。专业套餐解锁了实时互动面试官——这是Warmup从未提供过的更具压力的测试。
试试看
使用Google登录。录制一个答案。免费套餐无需付费即可使用完整的题库、评分答案和模拟面试。
如果你发现这篇文章中有你本会做出不同选择的架构决策,我想听听你的想法。直接回复这里,或者从网站获取我的邮箱。
采用 Go、React、Postgres 和 Google Cloud 构建。整个后端小到可以在一个下午读完。上面展示的路由模式、SSE 代理、pgvector 查询和 webhook 处理器是承重部分——其他都是胶水代码。
请识别以下文本的语言,并将其翻译成 简体中文:*如果这有用的话,一个❤️可以帮助帖子接触到更多的开发者。本系列的下一篇文章将深入探讨SSE代理模式(SSE broker pattern)——用Go实现它,无需外部依赖,我遇到的边界情况,以及何时应该使用fo。























