前端开发··2 阅读·预计 16 分钟

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"来写,和把它当一个有完整类型契约的外部服务来封装,差距就是凌晨三点会不会被报警叫醒。

0 评论

评论区

登录 后参与评论