定义provider契约层将LLM提供商细节隔离在代码库之外,防止API密钥泄露进客户端bundle。
周中,一位同事直接把模型 SDK 调用粘贴到了 React 组件里。到了周一演示跑通了,周三你就被要求更换模型、加认证、流式响应,还要解释为什么 API key 泄露到了客户端 bundle 里。这就是本文要讨论的失败模式:功能从来没有契约,所以每一层都吸收了本不该看到的提供商细节。
下面是我的做法:当一个全栈应用需要 AI 能力时,首先定义 provider 契约,然后在契约后面实现一层接缝(seam),只有在那之后才让 UI、存储和认证与这个接缝通信。我会完整走一遍这个纵向切片(聊天端点 → provider 接缝 → 流式 UI),你可以在本地运行它,在免费额度上验证后再投入预算。
一个文件,不依赖任何供应商的导入:
// src/server/llm/contract.ts
export type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };
export interface ChatRequest {
messages: ChatMessage[];
maxTokens?: number;
userId: string; // required: usage attribution and rate limiting
}
export interface ChatProvider {
streamChat(req: ChatRequest): AsyncIterable<string>;
}
export class ProviderError extends Error {
constructor(
public readonly code: 'RATE_LIMITED' | 'UNAVAILABLE' | 'BAD_REQUEST',
message: string,
) {
super(message);
}
}
十行代码里有三个决策,每一个都是因为之前没这样做而踩过坑:
userId 在请求中是强制的。第一次我漏掉它之后,就没有办法归因成本,也没有办法对前端中失控的重试循环做限流。
provider 返回的是 AsyncIterable<string> 的 token 流,而不是供应商的响应对象。一旦响应结构泄漏过接缝,切换 provider 就变成了跨整个代码库的重构,而不是改一个文件的事。
错误收敛为三个码,你的 API 层可以将它们翻译成 HTTP 响应(429、503、400),并在适当的地方加上 Retry-After。供应商特定的错误层次结构留在实现内部。
// src/server/llm/http-provider.ts
import { ChatProvider, ChatRequest, ProviderError } from './contract';
export class HttpChatProvider implements ChatProvider {
constructor(
private readonly baseUrl: string,
private readonly apiKey: string,
private readonly model: string,
) {}
async *streamChat(req: ChatRequest): AsyncIterable<string> {
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages: req.messages,
max_tokens: req.maxTokens ?? 512,
stream: true,
}),
});
if (res.status === 429) throw new ProviderError('RATE_LIMITED', 'upstream limit');
if (!res.ok || !res.body) throw new ProviderError('UNAVAILABLE', `status ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) return;
buffer += decoder.decode(value, { stream: true });
// parse server-sent events; yield token deltas only
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ') || line.includes('[DONE]')) continue;
const delta = JSON.parse(line.slice(6));
const token = delta.choices?.[0]?.delta?.content;
if (token) yield token;
}
}
}
}
注意这个类不知道什么:不知道 auth session、不知道数据库、不知道 React、不知道部署在哪个环境。这些都属于它周围的层。
Express 路由负责 auth、rate limiting 和持久化;它把实际生成委托给 provider:
// src/server/routes/chat.ts
router.post('/api/chat', requireSession, async (req, res) => {
const provider = req.app.locals.chatProvider as ChatProvider; // injected at startup
const userId = req.session.userId;
if (!(await rateLimiter.allow(userId))) {
return res.status(429).json({ error: 'slow down', retryAfterSeconds: 30 });
}
res.setHeader('Content-Type', 'text/event-stream');
try {
let full = '';
for await (const token of provider.streamChat({ messages: req.body.messages, userId })) {
full += token;
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
await db.messages.save(userId, full); // persistence lives here, not in the provider
res.end();
} catch (e) {
if (e instanceof ProviderError && e.code === 'RATE_LIMITED') {
return res.status(429).json({ error: 'provider busy', retryAfterSeconds: 10 });
}
return res.status(503).json({ error: 'assistant unavailable' });
}
});
前端用 fetch + ReadableStream 消费 SSE 流,从来不导入模型 SDK。如果明天切换 provider,客户端 diff 是零。
Disclosure: 本文是 MonkeyCode 产品推广的一部分。
在预生产阶段,我把接缝指向 MonkeyCode,它目前提供免费模型访问和免费服务器选项,所以 provider URL、key 和 model name 都来自环境变量,在"免费验证"和"付费生产"之间不需要改任何代码:
CHAT_BASE_URL=...
CHAT_API_KEY=...
CHAT_MODEL=...
免费阶段的目的不是对 provider 做基准测试,而是低成本地暴露集成失败。把免费额度当作一份保质期未知的礼物:不要把它的限制硬编码到容量计划里,也不要做一个仅仅因为免费额度恰好够用就能跑的 demo。
这个切片的预生产测试列表,全部可用免费额度运行:
未认证请求 → 401,零 provider 调用(用 stub provider 做断言)。
单个用户 20 个请求的 burst → 429 带 retryAfterSeconds,客户端退避。
上游 rate limit(stub provider 抛出 RATE_LIMITED)→ 客户端收到 503/429,绝不暴露原始堆栈跟踪。
流中断时断开连接 → 客户端看到截断的流,可以重新请求;部分文本不会被保存为完整消息。
Provider 切换演练 → 把环境变量指向第二个 provider,确认唯一要改的文件是启动时的 wiring。
持久化检查 → 保存的消息归因到正确的 userId,第二个用户无法读取。
测试 5 是人们最容易跳过但最后会后悔的。如果切换 provider 需要超过半天,你的接缝还不是真正的接缝。
单 provider 接缝对于一个永远不会发布的临时原型来说是多余的开销。等功能存活到第二周再写接缝。
某些托管平台上的流式 SSE 有缓冲 quirks;在实际部署目标上验证 flush 行为,而不只是本地。
免费额度用于集成验证,不是用于负载测试。Launch 之前你还是得根据自己的流量模型做真正的容量估算。
如果需要 provider 特定的功能(使用供应商特定 schema 的工具调用、微调模型管理),薄薄的契约覆盖不了。慎重地扩展接口,而不是在上面打洞。
[ ] 契约文件零供应商导入
[ ] 每次 provider 调用都必须有 userId
[ ] 错误码在一个地方映射到 HTTP 响应
[ ] 用 stub provider 测试 rate limit + auth
[ ] Provider 切换演练至少执行一次
[ ] 在客户端验证部分流处理
[ ] 用第二个用户测试持久化和权限
在你的设置中,哪个边界最不稳定——provider-to-API 边界,还是 API-to-client 流?如果你在那里遇到过特定的失败状态(状态码、截断的 SSE、被吞掉的错误),我愿意听听你收到的具体响应。