TypeScript 类型安全的 ChatGPT API 封装:从裸调 fetch 到生产级 SDK
前言
大多数开发者和 ChatGPT 的第一次交互长这样:
// ❌ 裸调——能用,但经不起推敲
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: 'hello' }],
}),
});
const data = await res.json();
console.log(data.choices[0].message.content);
// 问题:data 是 any,拼写错误、字段缺失全在运行时才暴露
这行代码背后至少有 4 个隐患:请求体无类型约束、响应体无类型推导、错误不区分处理、流式响应没考虑。本文把这一行代码拆开,用 TypeScript 重构成一个"敢上生产"的封装。
1. 请求体类型建模:别把 JSON 当 any
先从类型层锁定 OpenAI Chat API 的契约:
// ✅ 从顶层到底层逐级定义类型
type Role = 'system' | 'user' | 'assistant' | 'tool';
interface Message {
role: Role;
content: string | null;
name?: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string; // JSON string,运行时再 parse
};
}
// 泛型约束:response_format 和 messages 联动
interface ChatRequest<T extends 'text' | 'json_object' = 'text'> {
model: string;
messages: Message[];
temperature?: number;
max_tokens?: number;
tools?: Tool[];
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
response_format?: T extends 'json_object'
? { type: 'json_object' }
: { type: 'text' } | undefined;
}
interface Tool {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema
};
}
用泛型把 response_format 和实际类型关联——当你声明 ChatRequest<'json_object'> 时,response_format 只能是 { type: 'json_object' },编译期堵死。
2. 响应体类型:覆盖所有分支
OpenAI 的响应不是"一个结构通吃"。finish_reason 不同,message 内容完全不同:
// ✅ 用 discriminated union 建模不同终止原因
interface ChoiceBase {
index: number;
finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null;
}
interface ChoiceStop extends ChoiceBase {
finish_reason: 'stop' | 'length' | 'content_filter';
message: { role: 'assistant'; content: string };
}
interface ChoiceToolCalls extends ChoiceBase {
finish_reason: 'tool_calls';
message: { role: 'assistant'; content: null; tool_calls: ToolCall[] };
}
type Choice = ChoiceStop | ChoiceToolCalls;
interface ChatResponse {
id: string;
object: 'chat.completion';
created: number;
model: string;
choices: Choice[];
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
}
这样使用时 TypeScript 能自动收窄:
const choice = response.choices[0];
if (choice.finish_reason === 'tool_calls') {
// TypeScript 知道这里是 ChoiceToolCalls,tool_calls 必定存在
const calls = choice.message.tool_calls;
} else {
// 这里 choice 被收窄为 ChoiceStop,content 必不为 null
console.log(choice.message.content.toUpperCase());
}
3. 流式响应解析:SSE 不要用 split('\n\n')
ChatGPT 的流式响应走 Server-Sent Events,但 API 返回的不是标准 SSE——chunk 可能被 TCP 拆包:
// ❌ 简单 split 碰到拆包就丢数据
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!; // 最后一行可能不完整,留到下次
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
yield JSON.parse(data) as StreamChunk;
}
}
}
关键点在 lines.pop()——把可能被截断的最后一行还给 buffer,下次读到时拼回去。这是 SSE 客户端的基础素养。
流式 chunk 的类型定义也要收窄:
interface StreamChunk {
id: string;
object: 'chat.completion.chunk';
created: number;
model: string;
choices: {
index: number;
delta: { role?: 'assistant'; content?: string; tool_calls?: Partial<ToolCall>[] };
finish_reason: 'stop' | 'length' | 'tool_calls' | null;
}[];
}
注意 delta 和普通响应的 message 不同——工具调用的参数是分片到达的,需要自己拼接。
4. Function Calling 类型推导:从字符串拼接到类型安全
Function Calling 最让人头疼的是参数是 JSON string,TypeScript 帮不上忙。用泛型把工具定义和回调签名锁死:
// ✅ 工具定义泛型化——入参类型和回调签名联动
type ToolHandler<TParams = Record<string, unknown>> = {
definition: Tool;
handler: (params: TParams) => Promise<string>;
};
function defineTool<T>(opts: {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema
handler: (params: T) => Promise<string>;
}): ToolHandler<T> {
return {
definition: {
type: 'function' as const,
function: { name: opts.name, description: opts.description, parameters: opts.parameters },
},
handler: opts.handler,
};
}
// 使用——params 有完整类型
const weatherTool = defineTool<{ city: string }>({
name: 'get_weather',
description: '获取城市天气',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: '城市名' } },
required: ['city'],
},
handler: async ({ city }) => {
// city 类型被自动推导为 string
return `${city}的天气:晴朗,25°C`;
},
});
defineTool 的泛型 T 同时约束了 parameters 的 JSON Schema 语义(虽然运行时没法强制,但类型层面文档化了)和 handler 的入参。
5. 错误分级处理:429 和 401 不该一视同仁
// ✅ 按错误码分级,消费端按需处理
class OpenAIApiError extends Error {
constructor(
public status: number,
public code: string | null,
public type: string,
message: string,
) {
super(message);
this.name = 'OpenAIApiError';
}
get isRateLimited() {
return this.status === 429;
}
get isAuthError() {
return this.status === 401;
}
get isServerError() {
return this.status >= 500;
}
get isRetryable() {
return this.isRateLimited || this.isServerError;
}
}
// 带指数退避的重试
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000,
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (err instanceof OpenAIApiError && err.isRetryable && attempt < maxRetries) {
const delay = baseDelay * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
continue;
}
throw err;
}
}
throw new Error('unreachable');
}
消费侧只用判断 isRetryable,不用记住每个状态码的含义。
总结
| 环节 | 反模式 | 正解 |
|---|---|---|
| 请求体 | Record<string, any> 打天下 | 细粒度 interface + discriminated union |
| 响应解析 | as any 强转 | finish_reason 驱动类型收窄 |
| 流式读取 | split('\n\n') | buffer + lines.pop() 防拆包 |
| 工具调用 | JSON.parse() 后 as 断言 | 泛型 defineTool 联动定义和回调 |
| 错误处理 | catch 一把抓 | 按状态码分级 + 指数退避重试 |
把 ChatGPT API 当"调个 HTTP"来写,和把它当一个有完整类型契约的外部服务来封装,差距就是凌晨三点会不会被报警叫醒。
评论区
登录 后参与评论