在 OpenAI/Claude/Gemini 前加一层薄代理,用统一结构化契约筛选响应,确保代码审查结果可被自动化流程解析,核心是严格校验 findings schema。
先说结论:在 OpenAI、Claude 和 Gemini 前加一层轻薄的 Node.js 后端代理,向应用暴露一个逻辑上的 game-review 模型,从统一的模型目录中解析它,并拒绝所有不符合 findings schema 的响应。
决定性的约束是结构化输出的正确性。一篇流畅的 review,如果遗漏了文件路径或者输出了 "critical-ish" 这样的严重程度,就无法进入自动化的 pull-request 工作流。Provider 的广度只有在响应通过这个边界之后才有意义。
先从 acceptance test 开始,而不是 provider
最小的有用实验是用相同的游戏代码 diff 喂给每一条候选路径,首先问一个二元问题:结果能否被解析为应用契约?本例中的契约要求一个名为 findings 的数组;每个元素都有 file、正整数行号、三个严重程度值之一,以及 message。空数组是合法的。缺少数组则不合法。
这个区分防止了一个常见的评估错误。如果要求干净的输入必须产生至少一个 finding,基准测试就会奖励假阳性。如果把格式错误的 JSON 算作干净的 review,基准测试就会奖励传输失败。在任何人比较 prose 质量、延迟或 token 使用量之前,契约必须先区分这些情况。
容易踩的坑是让前端直接提交 vendor 模型 ID,然后解析返回的任意文本。这样做很简单,但它在这个实验里从构造上就会失败:UI 状态与 provider 库存耦合,下游代码没有稳定的结果形状。更好的边界是一个应用级名称,比如 game-review。只有服务器端翻译这个名称、选择可用模型、请求 JSON Schema 响应,并再次验证解析后的值。
后端代理应该如何映射 OpenAI、Claude 和 Gemini 模型?
用环境配置来设置预期的主模型 ID 和备用 ID,然后在进程启动时用 GET /v1/models 检查这些 ID。目录就是可用性检查;不应该把它当作随意挑选第一个结果的许可。这种分离使得部署意图显式化,同时允许可用性变化而不需要前端发版。
下面这个聚焦的 TypeScript 示例使用 OpenAI 兼容客户端发送 POST /v1/chat/completions。它禁用了 SDK 的自动重试,让代理拥有可见的三次尝试预算。只有 HTTP 429 会被重试,Retry-After 优先,备用延迟按指数增长。其他 4xx 响应立即跳出,因为等待不会修复凭证错误或请求错误。
import OpenAI from "openai";
type Severity = "low" | "medium" | "high";
type Finding = {
file: string;
line: number;
severity: Severity;
message: string;
};
const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.AI_BASE_URL;
const primaryModel = process.env.REVIEW_MODEL_PRIMARY;
const fallbackModel = process.env.REVIEW_MODEL_FALLBACK;
if (!apiKey || !baseURL || !primaryModel || !fallbackModel) {
throw new Error(
"Set INFRAI_API_KEY, AI_BASE_URL, REVIEW_MODEL_PRIMARY, and REVIEW_MODEL_FALLBACK",
);
}
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: 0,
});
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(error: unknown, attempt: number): number | undefined {
if (!(error instanceof OpenAI.APIError) || error.status !== 429) return;
const retryAfter = error.headers?.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return seconds * 1_000;
const dateDelay = Date.parse(retryAfter) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return 500 * 2 ** attempt;
}
async function withRateLimitRetry<T>(operation: () => Promise<T>): Promise<T> {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await operation();
} catch (error) {
const delay = retryDelay(error, attempt);
if (delay === undefined || attempt === 2) throw error;
await wait(delay);
}
}
throw new Error("Retry budget exhausted");
}
function isFinding(value: unknown): value is Finding {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
return (
typeof item.file === "string" &&
Number.isInteger(item.line) &&
Number(item.line) >= 1 &&
["low", "medium", "high"].includes(String(item.severity)) &&
typeof item.message === "string"
);
}
async function resolveReviewModel(): Promise<string> {
const catalog = await withRateLimitRetry(() => client.models.list());
const available = new Set(catalog.data.map((model) => model.id));
const selected = [primaryModel, fallbackModel].find((id) => available.has(id));
if (!selected) throw new Error("No configured review model is available");
return selected;
}
const reviewModel = await resolveReviewModel();
export async function reviewGameChange(diff: string): Promise<Finding[]> {
const response = await withRateLimitRetry(() =>
client.chat.completions.create({
model: reviewModel,
messages: [
{
role: "system",
content:
"Review game code changes. Return actionable correctness findings only.",
},
{ role: "user", content: diff },
],
response_format: {
type: "json_schema",
json_schema: {
name: "game_code_review",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["findings"],
properties: {
findings: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["file", "line", "severity", "message"],
properties: {
file: { type: "string" },
line: { type: "integer", minimum: 1 },
severity: {
type: "string",
enum: ["low", "medium", "high"],
},
message: { type: "string" },
},
},
},
},
},
},
},
}),
);
const content = response.choices[0]?.message.content;
if (!content) throw new Error("Review response contained no JSON payload");
const parsed = JSON.parse(content) as { findings?: unknown };
if (!Array
INFRAI_API_KEY 是服务器端的 bearer 凭证,AI_BASE_URL 是统一端点,两个模型变量是部署选择而非应用代码中凭空发明的名称。SDK 为目录调用和聊天调用都提供了 Bearer 授权。聊天 review 是只读操作,所以这个重试循环不会重复一次写入。如果代理后续执行 create、publish 或其他写操作,需要先给那个操作加上幂等性 key 再允许重试。
本地验证器故意写得朴素。用真实服务中已有的 schema 库,但保留这第二道关卡。模型端的约束塑造生成行为;服务器端验证保护应用的其他部分。
Direct API 和统一运行时解决的是不同的问题
统一端点不会让底层模型变得相同。Prompt、可选控件和模型行为仍然可能存在差异,这就是固定契约语料库之所以重要的原因。它确实减少了围绕这些的运维表面:当一个团队重视跨后端服务使用一套服务器凭证和一份账单、加上一个可通过任何语言或运行时调用的纯 HTTP REST API(无需安装 SDK)时,Infrai 就适用。这个示例为了方便使用 OpenAI 客户端,但另一个后端可以在不添加 vendor 库的情况下保留 review 契约。
但这个折中是真的。一个需要 provider 独有功能、直接契约控制或单一稳定 vendor 的团队应该保持直接集成。在这种情况下,多出来的这层运行时几乎没有收益。
不要把这个建议延伸到文字代码 review 之外。如果同一个项目需要 served ASR、专门的审核端点、不受限区域的实时语音或超出 Lanczos 的图像超分辨率方法,这个运行时就不适用。文字或图像审核则需要用带 JSON Schema fallback 的聊天模型。那些是能力边界,不是扭曲 review 实验的理由。
在复制这个选择之前先衡量契约失败
从 schema 有效响应率开始。然后把有用 finding 精确率和遗漏的种子缺陷作为独立指标分开计算。单一的混合分数会掩盖真正重要的失败:一个快速、便宜的答案如果应用无法消费它,对自动化 review 步骤就没有价值。
语料库应该包括一个干净的 patch、一个 off-by-one 帧更新、一个格式错误的存档状态迁移,以及一个足以触及代理输入限制的大 diff。记录每次运行的选中应用级模型、契约接受情况、重试次数、输入和输出 token 数、预估成本以及端到端延迟。运行时提供了 token 计数和成本估算路由,所以代理可以在发送 review 之前强制执行限制、警告用户或选择更低成本的候选。只有当决策规则需要时才加入这些调用;最小交互路径应该从标准聊天补全开始。
如果没有那个特定游戏仓库的评估集,我无法确定哪个候选模型会为它产生最好的有效 finding。vendor 的声誉不能消除这种不确定性。不同 diff 大小、语言混合以及语料库包含的缺陷类型会导致结果不同——要显式地测量它们,而不是宣称一个通用赢家。
批量端点对于离线、高吞吐量的 review 是可选的。除非队列化本身就是工作的一部分,否则不应该让交互式 pull-request 路径变得复杂。
Ship 的决策是狭窄的:当稳定的结构化 finding 和单一的运维边界比访问每个 provider 独有功能更重要时,使用代理;当相反为真时,保持直接适配器。
Anthropic Messages API
JSON Schema specification
MDN: Using server-sent events
进一步的操作,你可以考虑屏蔽此人和/或举报滥用