务实拟人化: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,按这个顺序排查:
- 检查 key 字符串本身有没有多余空格或换行
- 确认环境变量在当前 shell 里能
echo出来 - 检查请求头的格式,不同 provider 要求不一样
- 确认 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 个典型输入就够了。记录每次的输出,对比变化。这样你能清楚地知道这次调整是让输出更稳定了,还是只是换了一种不稳定方式。这个习惯帮我省了很多“感觉好像好了一点”的自我欺骗。