务实拟人化:TypeScript 调用 LLM API 的工程实践与避坑指南

发布时间:2026/11/2 5:38:34
务实拟人化:TypeScript 调用 LLM API 的工程实践与避坑指南

1. 从“拟人化”这个词说起:为什么我们总想跟自动补全聊天

第一次看到“Pragmatic Anthropomorphism”这个说法,我脑子里蹦出来的画面特别具体:深夜两点,我对着一个代码补全窗口敲下半行注释,然后盯着它吐出来的建议,心里默念“你倒是懂我意思啊”。明明知道对面只是一堆矩阵乘法在跑前向传播,但你就是忍不住把它当成一个“有点笨但很努力的实习生”。这种心态,就是务实拟人化——不是真的相信它有意识,而是这种心智模型在工程上确实好用。

这个标题里的“Autocompleting Cricket”是个很妙的比喻。蟋蟀的叫声有节奏、有规律,但你永远不知道它下一声什么时候响、响几声。大语言模型的输出也是这样:大部分时候靠谱,偶尔抽风,你没法完全预测,但又不能不用。所以问题就变成了——既然要跟这么个东西长期打交道,怎么“说话”才能让它稳定干活?

这篇东西适合谁看?如果你正在用 TypeScript 写调用 LLM API 的代码,或者你在用 Claude Code、GPT-4 这类工具做日常开发,又或者你只是好奇“为什么我写的 prompt 有时候灵有时候不灵”,那接下来的内容应该对你有用。我会从 API 调用的实际代码出发,聊到 prompt 设计的心理模型,再落到几个我踩过的坑和总结出来的土办法。不搞玄学,只讲能复现的操作。

关键词里出现了 LLM、API、TypeScript、GPT-4、Claude,这几个词基本框定了讨论范围:我们是在工程语境下谈怎么跟大模型交互,不是哲学讨论。务实拟人化的“务实”两个字很重要——拟人化只是手段,目的是让接口调用更稳定、让输出更可控、让调试更高效。

2. 把 LLM 当成一个“有性格的 API”来设计调用层

2.1 为什么直接 fetch 裸调 API 迟早会出问题

我见过太多项目一开始都是这样写的:一个fetch丢过去,await response.json(),然后data.choices[0].message.content取出来用。前两周跑得好好的,第三周开始偶尔报unexpected status 401 unauthorized: incorrect api key provided,或者突然来个api error: 400 this model's maximum context length is 1048576 tokens。然后你就开始到处加try-catch,代码越来越乱。

问题的根源在于:你把 LLM 当成了一个普通的 REST API。但它不是。普通 API 的输入输出是确定性的,同样的请求永远返回同样的结果。LLM 不是。同样的 prompt,今天返回 A,明天返回 B,温度参数稍微动一下,输出风格就完全变了。更麻烦的是,它的“状态”不在你这边——上下文长度、token 计费、模型版本更新,这些都在服务端悄悄变化。

所以第一件事:在 TypeScript 里给 LLM 调用封一层。这层封装要解决几个具体问题——重试策略、超时控制、token 估算、错误分类。我自己的做法是定义一个LLMClient接口,然后针对不同 provider 做适配器。这样上层业务代码不关心你用的是 GPT-4 还是 Claude,只关心“给我一段文本,我告诉你我要什么”。

interface LLMRequest { systemPrompt: string; userMessage: string; maxTokens?: number; temperature?: number; retryOnFailure?: boolean; } interface LLMResponse { content: string; tokenUsage: { prompt: number; completion: number; total: number; }; model: string; latencyMs: number; } interface LLMClient { complete(request: LLMRequest): Promise<LLMResponse>; }

这个接口看起来简单,但每个字段都有讲究。systemPrompt和userMessage分开,是因为大多数 provider 对 system 角色的处理方式不同——有的模型对 system prompt 的遵循度更高,有的几乎忽略。分开之后你可以针对不同模型调整策略。retryOnFailure不是简单的重试开关,而是要根据错误类型决定:401 重试没用,得换 key;429 要退避重试;400 里的 context length 超限,重试也没用,得截断输入。

2.2 错误分类比错误处理更重要

热词里出现了好几个具体的错误信息,我挑两个典型的说。unexpected status 401 unauthorized: incorrect api key provided: sk-svcac****这个错误,表面看是 key 不对,但实际上有三种可能:key 真的写错了、key 过期了、或者环境变量没加载上。我在 TypeScript 里处理这个的时候,不会只抛一个Error,而是定义一个错误码枚举:

enum LLMErrorCode { AUTH_FAILED = 'AUTH_FAILED', RATE_LIMITED = 'RATE_LIMITED', CONTEXT_TOO_LONG = 'CONTEXT_TOO_LONG', MODEL_UNAVAILABLE = 'MODEL_UNAVAILABLE', NETWORK_ERROR = 'NETWORK_ERROR', UNKNOWN = 'UNKNOWN', }

然后根据 HTTP 状态码和响应体里的错误信息做映射。401 映射到AUTH_FAILED,429 映射到RATE_LIMITED,400 里如果 message 包含 “maximum context length” 就映射到CONTEXT_TOO_LONG。这样做的好处是,上层代码可以根据错误码做不同的决策,而不是笼统地“出错了,重试一下”。

另一个热词里出现的api error: 400 this organization has been disabled这种,属于配置层面的问题,重试一万次也没用。这时候应该直接告警,让人去检查账号状态。我在实际项目里会加一个isRetryable的判断函数:

function isRetryable(code: LLMErrorCode): boolean { return [LLMErrorCode.RATE_LIMITED, LLMErrorCode.NETWORK_ERROR].includes(code); }

这个函数看起来简单,但它把“什么值得重试”这个决策从业务逻辑里抽出来了。你不需要在每个调用点都写一遍if (error.status === 429 || error.status >= 500)。

2.3 Token 估算:别等报错了才算

this model's maximum context length is 1048576 tokens这个错误,很多人是等到线上炸了才发现。1M token 听起来很多,但如果你把整个代码库塞进去做 RAG,或者把长对话历史全量传过去,很容易就超了。我的做法是在客户端做一层粗略估算:英文大约 4 个字符 1 个 token,中文大约 1.5 个字符 1 个 token。不精确,但足够在发送前拦住明显超限的请求。

function estimateTokens(text: string): number { const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const otherChars = text.length - chineseChars; return Math.ceil(chineseChars / 1.5 + otherChars / 4); }

然后在complete方法里加一个前置检查:如果estimateTokens(systemPrompt) + estimateTokens(userMessage) > maxContext * 0.8,就主动截断或者抛一个明确的错误。留 20% 的余量是因为估算本身有误差,而且输出也需要占用 token 预算。

注意:不同模型的 tokenizer 不一样,同样的文本在不同模型下的 token 数可能差 20% 以上。所以这个估算函数要按模型做微调,不能一套参数打天下。

3. Prompt 不是“说话”,是“写接口文档”

3.1 把 system prompt 当成类型定义来写

很多人写 prompt 像在跟朋友发微信:“帮我总结一下这段代码”。这种写法在简单场景下能用,但一旦逻辑复杂一点,输出就开始飘。我的经验是:把 system prompt 当成 TypeScript 的 interface 来写。你不是在“请求”模型做一件事,你是在“定义”它应该输出什么结构。

举个例子,如果你要模型从一段文本里抽取实体,不要写“请提取里面的人名和地名”。要写成:

你是一个信息抽取引擎。对于给定的输入文本,输出一个 JSON 对象,包含两个字段: - persons: string[],文本中出现的所有人名 - locations: string[],文本中出现的所有地名 规则: 1. 只输出 JSON,不要有任何其他文字 2. 如果某个字段没有内容,返回空数组 3. 人名和地名保持原文中的写法,不要翻译

这看起来啰嗦,但每一条规则都是在减少输出的不确定性。第一条防止它加“好的,以下是提取结果”这种废话。第二条防止它返回 null 或者省略字段。第三条防止它自作主张把“张三”翻译成 “Zhang San”。

这种写法的底层逻辑是:LLM 的输出空间是无限的,你的 prompt 是在这个无限空间里划一个尽可能小的区域。划得越小,输出越稳定。

3.2 用 few-shot 示例来“校准”而不是“教学”

Few-shot 示例很有用,但很多人用错了。他们放一堆示例,希望模型“学会”任务。但实际上模型不需要学,它需要的是校准——你通过示例告诉它“我要的是这个粒度、这个格式、这个语气”。

我通常放 2 到 3 个示例就够了。第一个示例展示最典型的情况,第二个展示边界情况,第三个展示“不要这样输出”的反例。比如做代码审查的 prompt:

示例1: 输入:function add(a, b) { return a + b; } 输出:{"issues": [], "summary": "函数实现正确,命名清晰"} 示例2: 输入:function add(a, b) { return a - b; } 输出:{"issues": [{"line": 1, "severity": "high", "message": "函数名为 add 但执行的是减法"}], "summary": "存在逻辑错误"} 示例3(反例,不要这样输出): 输入:function add(a, b) { return a + b; } 错误输出:这个函数看起来没问题,它把两个数相加并返回结果。代码风格也不错。 正确输出:{"issues": [], "summary": "函数实现正确,命名清晰"}

第三个示例特别关键。它明确告诉模型“我不要自然语言解释,我要 JSON”。实测下来,加了反例之后,输出格式的合规率能从 70% 左右提到 95% 以上。

3.3 温度参数不是“创造力旋钮”

很多人把 temperature 当成“创意程度”的调节器,调高就是“更有创意”,调低就是“更保守”。这个理解不算错,但在工程场景下容易误导。我的经验是:对于需要结构化输出的任务,temperature 直接设 0 或者 0.1。不要犹豫。你不需要模型在生成 JSON 的时候“有创意”。

对于需要多样化输出的场景,比如生成测试用例、写文案,可以调到 0.7 到 0.9。但即使这样,也要配合 top_p 一起调。我一般用 temperature 0.8 + top_p 0.9 作为起点,然后根据实际输出微调。

还有一个容易被忽略的参数是 presence_penalty 和 frequency_penalty。如果你发现模型总是重复某些句式,可以适当调高 frequency_penalty。但这两个参数对结构化输出的影响比较大,调之前先确认你的输出格式不会因此被破坏。

4. 当 Claude Code 和 GPT-4 表现不一样时,怎么调整策略

4.1 不同模型对 system prompt 的“服从度”差异

热词里同时出现了 GPT-4 和 Claude,这两个模型在实际使用中性格差异很明显。我的体感是:GPT-4 更倾向于“完成你交代的任务”,Claude 更倾向于“理解你的意图然后给你一个它认为更好的结果”。这个差异在写代码的时候特别明显。

比如你让 GPT-4 写一个排序函数,它大概率会给你一个标准的快排或者归并。你让 Claude 写同样的东西,它可能会先问你“数据量多大?需要稳定排序吗?”——当然在 API 调用里它不会真的问你,但它会在输出里加入很多它自己判断的“优化”。

所以针对 Claude,我的 system prompt 会加一句:“严格按照以下规格实现,不要添加额外的功能或优化。”针对 GPT-4,我会加:“确保代码包含完整的错误处理和边界检查。”这不是说 GPT-4 不写错误处理,而是它的默认风格更简洁,需要你明确要求才会加。

4.2 Claude Code 的 workspace 配置坑

热词里有一条claude鈥檚 workspace requires the virtual machine platform on windows. enable,这个错误我在 Windows 上遇到过。Claude Code 的某些功能依赖虚拟化平台,在 Windows 上需要手动开启。具体操作是:打开“启用或关闭 Windows 功能”,勾选“虚拟机平台”和“Windows 虚拟机监控程序平台”,然后重启。这个不是 Claude Code 独有的问题,很多需要轻量虚拟化的开发工具在 Windows 上都有类似要求。

另一个常见问题是your organization has disabled claude subscription access for claude code。这个错误的意思是组织管理员关闭了 Claude Code 的订阅访问权限。如果你是在公司环境里用,需要找管理员确认策略。如果是个人账号,检查一下订阅状态是否正常。

4.3 本地模型和云端模型的切换策略

热词里有一条claude code 调用 lmstudio 的本地模型,这个思路很实用。我的做法是:日常简单的代码补全和格式化用本地模型,复杂的重构和架构设计用云端模型。本地模型的优势是延迟低、不花钱、数据不出本机。劣势是能力上限明显。

在 TypeScript 里做这个切换,可以在LLMClient接口下面挂两个实现:LocalLLMClient和CloudLLMClient。然后写一个RoutingLLMClient,根据任务类型自动路由。比如:

class RoutingLLMClient implements LLMClient { constructor( private local: LLMClient, private cloud: LLMClient, ) {} async complete(request: LLMRequest): Promise<LLMResponse> { if (this.isSimpleTask(request)) { return this.local.complete(request); } return this.cloud.complete(request); } private isSimpleTask(request: LLMRequest): boolean { const simplePatterns = [ /格式化/, /重命名/, /添加注释/, /fix typo/i, ]; return simplePatterns.some(p => p.test(request.userMessage)); } }

这个路由逻辑很粗糙,但能省不少钱。实测下来,日常开发中大概 60% 的 LLM 调用都是简单任务,本地模型完全够用。

5. 那些报错信息背后真正要检查的东西

5.1 401 不一定是 key 的问题

unexpected status 401 unauthorized: incorrect api key provided: sk-svcac****这个错误,我踩过三次坑。第一次是 key 真的写错了,复制的时候少了一位。第二次是环境变量没加载——.env文件在项目根目录,但运行脚本的工作目录不对。第三次最隐蔽:key 是对的,但请求头里的Authorization字段格式不对,少了Bearer前缀。

所以看到 401,按这个顺序排查:

  1. 检查 key 字符串本身有没有多余空格或换行
  2. 确认环境变量在当前 shell 里能echo出来
  3. 检查请求头的格式,不同 provider 要求不一样
  4. 确认 key 对应的账号没有欠费或过期

5.2 400 错误里的信息量最大

400 错误通常包含具体的错误原因,但很多人只看状态码就慌了。实际上 400 的响应体里往往有message字段,告诉你具体哪里不对。比如maximum context length告诉你是输入太长了,organization has been disabled告诉你是账号问题。

我的做法是在错误处理里把 400 的响应体完整打出来,不要只打状态码。然后在日志里加一个字段记录requestId,方便跟 provider 的技术支持沟通。

5.3 超时和重试的配合

LLM 调用的延迟波动很大。同样的请求,有时候 2 秒返回,有时候 30 秒。如果你设了 10 秒超时,那 30 秒的那些请求就会失败。但如果你设了 60 秒超时,用户等 60 秒才看到错误,体验也很差。

我的策略是分层超时:连接超时设 5 秒,读取超时设 30 秒,整体超时设 45 秒。然后在 30 秒的时候给用户一个“还在处理中”的提示。重试策略用指数退避:第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 次。但只对RATE_LIMITED和NETWORK_ERROR重试,其他错误直接抛。

async function withRetry<T>( fn: () => Promise<T>, maxRetries: number = 3, ): Promise<T> { let lastError: Error; for (let i = 0; i <= maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error as Error; if (!isRetryable(classifyError(error))) { throw error; } if (i < maxRetries) { await sleep(Math.pow(2, i) * 1000); } } } throw lastError!; }

6. 务实拟人化的边界:什么时候该停止“跟它聊天”

拟人化是个好用的心智模型,但它有边界。当你开始花大量时间“调教”prompt、试图“说服”模型理解你的意图时,可能已经走偏了。我的判断标准是:如果同一个任务,你调整 prompt 超过 5 次还是不稳定,那就不是 prompt 的问题,是任务本身不适合用 LLM 做,或者你需要换一个模型。

另一个边界是:不要依赖模型的“记忆”。LLM 没有记忆,每次调用都是独立的。你所谓的“上下文”是你自己传进去的。所以不要把重要信息只放在对话历史里,要放在 system prompt 或者结构化的输入里。

还有一个实际经验:当你发现自己在 prompt 里写“请仔细思考”“一步一步来”的时候,先停一下。这些词对某些模型有用,对另一些模型几乎没影响。与其加这些“咒语”,不如把任务拆成更小的步骤,每一步单独调用。比如先让模型提取关键信息,再让模型基于提取的信息做判断。这样比在一个 prompt 里让模型“仔细思考”要可靠得多。

最后说一个我自己的习惯:每次调整 prompt 之后,跑一个固定的测试集。不用很大,10 到 20 个典型输入就够了。记录每次的输出,对比变化。这样你能清楚地知道这次调整是让输出更稳定了,还是只是换了一种不稳定方式。这个习惯帮我省了很多“感觉好像好了一点”的自我欺骗。