☰
从裸JS迁移到TS 5.7:AI应用开发如何少踩三个坑
2026/10/26 5:41:22 网站建设 项目流程

前阵子我把一个AI应用从裸JS重构成了TS 5.7。重构的起因特别朴素:连续两周被三个不起眼的问题轮番坑,每个问题单拎出来都不算难,合在一起就让我意识到,裸JS在大模型应用里的松散不是性格问题,是会咬人的隐患。

这篇文章算是一次经验复盘,不是“TypeScript 推广宣言”。我想聊的是在AI应用开发这个具体场景里,当你调用大模型、解析返回内容、定义function calling工具、管理一堆API Key和模型名配置的时候,TS 5.7为什么能比裸JS少踩三个坑。内容来自我这几个月实际做智能体应用、接多模态模型、处理流式输出的经历,适合正在用Node.js写AI应用、或者准备把AI功能接进现有服务的开发者参考。

读完你至少能带走三样东西:第一,三个具体的裸JS翻车现场;第二,每个翻车现场对应的TS 5.7解法;第三,一条从裸JS平滑迁到TS 5.7的实操路径,包括tsconfig怎么配、哪几类报错最花时间、哪些场景其实不值得上TS。很多人入门大模型开发时看到的第一段代码就是JavaScript,这没问题,但项目一旦活过三个月,类型系统的价值就会显现出来。

1. 为什么我在AI应用里把裸JS换成了TS 5.7

1.1 裸JS写AI应用确实很爽

先承认一件事:裸JS写AI应用真的很爽。你只要有一台能装Node的机器,npm install一个SDK,或者干脆用内置的fetch,十分钟就能调通一个大模型对话接口。没有构建步骤、没有类型定义、没有tsconfig,随便一个脚本文件扔给node就能跑。现在很多大模型开发入门课程的第一课,画风基本都是这样:一段fetch,一个JSON,控制台打印出模型的回复。我一开始也是这么干的。

这种爽感在demo、原型验证、个人实验阶段完全够用。如果目标只是“给模型发一句话,把回复打印出来”,裸JS确实比任何带类型系统的方案都更直接,因为代码量根本撑不起类型保护的成本。我自己也写过不少这种一次性的调用脚本,测prompt、比模型、看返回格式,裸JS是真方便。

真正出问题的是项目过了那个“玩具阶段”以后。AI应用和普通HTTP服务有个本质区别:普通服务里数据结构是你自己定义的,是你往外发的;AI应用里数据结构有一半是模型返回的,是你不可控的。这就形成了一种很拧巴的局面——代码骨架由你写,但血肉是模型的,而模型会吐出什么东西,经常不在你的预期范围里。这时候裸JS的“自由”就变成了双刃剑。

1.2 两周之内被坑了三次

我的项目是一个智能体应用,核心流程是:用户提需求,模型决定调用哪些工具,程序执行工具,把结果回传给模型,模型再生成最终回答。这个流程跑起来之后,头两周我遇到了三个高频问题:

  • 解析模型返回的JSON时,某个字段在一条请求里有、另一条请求里没有,代码直接访问这个字段,到了运行时才报错。
  • function calling里,我发给模型的参数schema和真实函数签名有出入,模型按schema生成参数,程序用真实函数调用,参数对不上,功能静默失败。
  • 换模型供应商、换本地模型的时候,配置里的模型名、baseURL、环境变量到处都是魔法字符串,改漏一处就立刻翻车。

这三个问题有一个共同点:出现概率不低,每次都得靠运行时日志定位,修复成本不高但特别磨人。当时项目里已经有人在用// @ts-check加JSDoc注释做零散的类型检查,但都不彻底,覆盖不到AI应用真正危险的那些边界。

正好那段时间TS 5.7发布,我试了试新版本,又研究了一轮它改进的递归类型别名推断、模板字符串类型支持,把核心代码迁了过去。三周后回头看,这三个坑不能说彻底消失,但大部分在编译期就被拦截了,根本活不到运行时。下面把这三个坑逐一展开,每个都附上裸JS的翻车现场和TS 5.7的替代写法。

2. 坑一:LLM返回的数据结构是“薛定谔的JSON”

2.1 同样的接口,两次返回两种鬼样子

先说最经典的一个。调用OpenAI兼容的chat completion接口,SDK返回的回复看起来结构很稳定:一个response对象,里面有一个choices数组,每个元素有message,message里有content。很多新手教程甚至只展示这一个形态,导致所有人都默认“返回结构就这样”。

但你多调几次就会发现,message.content经常会变成null——尤其当模型决定调用工具而不是直接说话的时候,content直接缺席,取而代之的是一个tool_calls数组。再比如choices,极少数情况下会是空数组,因为请求被内容审核或策略性原因拦住了。如果代码写的是:

// 裸JS const data = JSON.parse(response); const text = data.choices[0].message.content;

这段代码在模型正常回复时毫无问题,但只要某次返回里choices是空数组,或者message.content是null,它就会在某个奇怪的位置抛出Cannot read properties of undefined。更讨厌的是,text被拿到之后还会继续传给UI渲染、塞进数据库,等你发现不对劲的时候,脏数据已经流出去好几层了。

还有一坑:大模型经常返回带markdown围栏的JSON字符串。比如你让它“只输出JSON”,它偏要给你包一圈```json ... ```。裸JS直接JSON.parse就抛异常,你得先写一个剥围栏的正则。这个不算类型问题,但它和类型问题混在一起时,排查成本会翻倍。

2.2 TS 5.7怎么把这个坑填平

TS这边的思路不是“猜对结构”,而是“先把不确定的地方亮出来,逼你在边界处理”。首先定义一个可辨识联合类型,描述模型消息的所有可能形态:

type ChatMessage = | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] } | { role: 'user'; content: string } | { role: 'system'; content: string }; interface ChatCompletionResponse { id?: string; choices: ChatCompletionChoice[]; // 注意:不保证非空 } interface ChatCompletionChoice { index: number; message: ChatMessage; finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter'; } interface ToolCall { id: string; type: 'function'; function: { name: string; arguments: string; // 通常是一段JSON字符串 }; }

然后解析的地方不要直接断言成any,而是用unknown接住,再通过类型守卫把结构收窄:

function parseChatResponse(raw: string): ChatCompletionResponse { const data: unknown = JSON.parse(raw); // 这里还可以加一层运行时结构化校验,见下文 return data as ChatCompletionResponse; } function isToolCallMessage(msg: ChatMessage): msg is Extract<ChatMessage, { role: 'assistant'; tool_calls: ToolCall[] }> { return msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0; }

处理时这样写:

const data = parseChatResponse(raw); const choice = data.choices[0]; // 开启noUncheckedIndexedAccess后,类型是Choice | undefined if (!choice) throw new Error('响应里没有choices,可能被策略拦截'); const msg = choice.message; if (isToolCallMessage(msg)) { // 走工具调用分支 for (const call of msg.tool_calls) { const args = JSON.parse(call.function.arguments); // args仍然可能和你预期不一致,但至少外围结构已经稳住了 } } else { // 走普通文本分支,content可能是null,必须显式处理 const text = msg.content ?? ''; console.log(text); }

这里有个点值得多说一句:TS 5.7对递归类型别名的推断改进很大。像ChatMessage这种引用着ToolCall[]、而ToolCall内部结构又有一定复杂度的类型,以前嵌套多了容易触发“类型实例化过深”的报错,5.7之后这个问题明显变少。也就是说,类型可以放心地建深,不用老担心编译器先罢工。

2.3 边界上再加一层运行时校验

当然,光声明类型是不够的。data as ChatCompletionResponse说到底是在告诉TS“我说了算”,如果真实返回结构跟类型定义完全不符,TS也帮不了你。真正稳妥的做法是在数据进入系统的那一刻,做一次真实的运行时校验。

我在项目里用的是zod,和TS类型几乎无缝配合。先定义一个和类型对应的校验schema:

import { z } from 'zod'; const ToolCallSchema = z.object({ id: z.string(), type: z.literal('function'), function: z.object({ name: z.string(), arguments: z.string() }) }); const ChatMessageSchema = z.discriminatedUnion('role', [ z.object({ role: z.literal('assistant'), content: z.string().nullable(), tool_calls: z.array(ToolCallSchema).optional() }), z.object({ role: z.literal('user'), content: z.string() }), z.object({ role: z.literal('system'), content: z.string() }) ]); const ChatCompletionResponseSchema = z.object({ id: z.string().optional(), choices: z.array(z.object({ index: z.number(), message: ChatMessageSchema, finish_reason: z.enum(['stop', 'length', 'tool_calls', 'content_filter']) })) });

解析时先过校验:

function parseChatResponse(raw: string): ChatCompletionResponse { const data: unknown = JSON.parse(raw); return ChatCompletionResponseSchema.parse(data); }

这样做的价值是:模型返回的结构一旦和预期偏离,错误在入口处就暴露了,而且zod的报错信息会精确到哪个字段缺失、哪个字段类型不对。后面的业务逻辑就可以放心信任TS类型推导的结果,因为运行时和编译期已经对齐了。

我推荐的边界处理范式是:unknown入口,runtime schema校验,类型收窄。三步走完,后面全是安全的。裸JS省掉这三步,等于把风险全堆到业务代码里,然后靠一双肉眼去翻日志。

2.4 多模态压过来之后,结构更不稳定

这两年多模态模型进展很快,我接到一个需求要处理模型返回的图片、坐标和文本混排内容。这类回复的结构比纯文本更不稳定:有的是content里塞一段markdown图片链接,有的走image_url字段,有的直接返回一个URL数组。在裸JS里,每适配一个供应商就要在代码里加一个if (data.xxx),改到后面自己都记不清哪个字段是哪家的了。

TS里我用带kind字段的联合类型建模多模态内容:

type MultimodalBlock = | { kind: 'text'; text: string } | { kind: 'image'; url: string; width?: number; height?: number } | { kind: 'tool_use'; toolName: string; args: Record<string, unknown> };

模型返回的content就定义成MultimodalBlock[],后续无论是渲染还是入库,都通过switch (block.kind)把类型收窄到具体分支。新增一种block类型时,编译器会逼着我把所有switch分支补齐,不会像裸JS那样漏掉一种情况然后静默吞掉。多模态模型的输出复杂度和不可控性都更高,类型系统在这里的作用不是锦上添花,而是刚需。

3. 坑二:Function Calling的Schema和真实函数总在撒谎

3.1 schema与函数签名脱节的翻车现场

第二个坑来自function calling,有些SDK也叫tool calling。当你告诉模型“你可以调用这些函数”时,需要把函数的名称、描述、参数schema一起传给模型。模型读到了这个schema,返回一个函数名和一段JSON格式的arguments,然后你的程序把arguments解析出来,调用真实函数。

这套流程里最常见的翻车点,就是schema里写的参数名和真实函数签名对不上。举个例子:

// schema里写的字段名 const toolDef = { name: 'update_order_status', parameters: { type: 'object', properties: { order_id: { type: 'string' }, new_status: { type: 'string', enum: ['pending', 'shipped', 'done'] } }, required: ['order_id', 'new_status'] } }; // 真实函数 function updateOrderStatus(orderId, newStatus) { ... } // 模型按schema生成参数 const args = JSON.parse(modelReturn.arguments); // args = { order_id: 'ord_123', new_status: 'shipped' } // 直接调真实函数 updateOrderStatus(args.order_id, args.new_status);

裸JS不会报任何错,args.order_id就是args.order_id,真实函数里拿到的orderId是undefined,newStatus也大概率接错。结果就是:用户端看起来工具调用了,模型的后续流程也走完了,但订单状态根本没更新——静默失败。

另一个常见问题是枚举值不匹配。schema里写enum: ['pending', 'shipped', 'done'],真实函数却只用字符串硬比较,模型返回一个你没列进去的值(比如'processing'),函数还是会执行,但行为完全没定义。这种错误在裸JS里只能在业务日志里慢慢抠,而且因为代码“没抛错”,很少会有人第一时间怀疑是schema和签名不一致。

3.2 用TS 5.7把schema和实现绑在一起

我的解法是把“函数签名”和“schema”用类型绑定起来,让它们永远无法分家。做法是定义一个工具对象,里面既带实现,也带一个由zod推导出的schema:

import { z } from 'zod'; const UpdateOrderStatusSchema = z.object({ orderId: z.string().describe('订单ID'), newStatus: z.enum(['pending', 'shipped', 'done']).describe('新的订单状态') }); type UpdateOrderStatusArgs = z.infer<typeof UpdateOrderStatusSchema>; function updateOrderStatus(orderId: string, newStatus: UpdateOrderStatusArgs['newStatus']) { // 业务实现,orderId和newStatus都是严格类型 } const updateOrderStatusTool = { name: 'update_order_status', description: '更新指定订单的状态', parameters: UpdateOrderStatusSchema, runSafe: (argsText: string) => { const args = UpdateOrderStatusSchema.parse(JSON.parse(argsText)); return updateOrderStatus(args.orderId, args.newStatus); } };

重点在这里:schema和运行入口在同一个对象里,字段名从同一个类型推导出来。模型返回的参数经过schema.parse校验,字段名不对立刻抛错,不会等函数内部默默吞掉。每个工具自己负责解析自己的参数,不存在一个通用runner去猜每个参数结构。

如果你愿意,还可以用z.function()把参数校验和执行结构写成一个整体:

const saferTool = z.function() .args(UpdateOrderStatusSchema) .returns(z.promise(z.object({ ok: z.boolean() }))) .implement(updateOrderStatus);

这样“schema、校验、执行”是一条链,中间没有任何脱节的地方。TS 5.7配合zod,重点不是“解析一遍”,而是让schema和真实函数天然就是同一份定义的两种视图,想不一致都难。

3.3 智能体批量注册工具时这招尤其值

智能体应用里工具通常不止一个。很多项目用一个tools数组把所有工具定义塞进去,裸JS看起来没什么问题,但工具多了维护就成了灾难:给模型看的schema是一份,真实函数的签名是另一份,文档写的是第三份,三份内容稍有分歧,模型就开始“幻觉”出不存在的参数。

TS的做法是把工具定义收敛成一个数组,每个元素都是“schema、类型、实现”的三位一体:

const tools = [ updateOrderStatusTool, createOrderTool, queryOrderTool ]; function dispatchTool(name: string, argsText: string) { const tool = tools.find(t => t.name === name); if (!tool) throw new Error(`未知工具: ${name}`); return tool.runSafe(argsText); // 每个工具自己负责解析参数 }

新增工具时,只要照葫芦画瓢加一个对象,把schema和runSafe写在一起,就不可能出现“别的文件里手写了一份参数定义,跟实际函数对不上”的情况。这个结构在智能体应用里特别常见,也是我体会最深的收益:类型保护的是“实现和描述的一致性”,而不是简单的不报错。

4. 坑三:环境配置和模型名的“魔法字符串”

4.1 魔法字符串造成的两起事故

AI应用里魔法字符串尤其多:模型名、baseURL、API Key、temperature、max_tokens、system prompt……这些东西几乎没有一个是代码从内而外能推导出来的,全是从配置里读的。裸JS项目通常的做法是:

const config = { model: 'gpt-4o', baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', apiKey: process.env.OPENAI_API_KEY, temperature: 0.7 };

看起来挺规整,但有两类问题会在实际运行中反复出现。

第一类是模型名和baseURL拼写错误。比如本地跑Ollama,baseURL你写成http://localhost:11434/v1,但实际服务对路径的处理和SDK的预期不完全一致,多点一个斜杠都可能404。再比如把gpt-4o拼成gpt-4o-mini,或者把供应商的模型名记错,API直接回400。裸JS里这些错误只有发请求那一刻才暴露,每次都要翻日志。

第二类是环境变量缺失。process.env.OPENAI_API_KEY如果没设置,裸JS里它就是undefined,SDK发请求时才会抛出“Missing API key”。有时候更麻烦:一个团队里有人用本地配置跑通了,另一个人没配,结果别人一跑就报错,最后发现是环境变量名大小写或者下划线位置写错了。

4.2 字面量类型和模板字符串类型把错误拦在编译期

TS的写法不是去验证运行时,而是让“不该出现的情况”根本过不了编译。最基础的是用一个联合类型把模型名固定住:

type ModelName = 'gpt-4o' | 'gpt-4o-mini' | 'claude-3-5-sonnet' | 'qwen-max'; interface AiConfig { model: ModelName; baseUrl: `https://${string}` | `http://localhost:${string}`; maxTokens: number; } const config = { model: 'gpt-4o', // 合法 baseUrl: 'https://api.openai.com/v1', // 合法 maxTokens: 2048 } satisfies AiConfig;

如果你把model写成'gpt4o',或者把baseUrl写成'api.openai.com/v1'(少了https://),satisfies会直接在编译期报错。这个错误发生的时候可能离真正发请求还有很远,但它已经在源头把写错的名字拦住了。

TS 5.7对模板字符串类型的支持更成熟了。其实模板字符串类型很早就有了,但以前在很多嵌套场景下表现不太稳定,5.7之后用起来顺了很多。你可以把URL规范化写成一个小类型约束:

type ApiBaseUrl = `https://${string}/v1`;

这样凡是baseUrl不满足https://开头、不以/v1结尾的值,都会在类型层面被拦下来。当然这种约束可以根据实际情况放宽,关键是让配置写入时先拦住低级拼写错误,真到运行时,配置还是要过一层“缺失即报错”的检查。

4.3 环境变量的非空保证

环境变量的处理上,TS有一个特别戳中我的点:开启strictNullChecks之后,process.env.XXX的类型是string | undefined。这其实是在逼你使用前先判断,而判断本身就是一种防御。

function getEnv(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`缺少环境变量: ${name}`); } return value; } export const env = { openaiApiKey: getEnv('OPENAI_API_KEY'), openaiBaseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1' };

这样环境变量只被读取一次,缺失就立刻暴露在应用启动时,而不是等到第一次调模型时才抛异常。这个模式后来我在私有化AI应用场景也用上了——那些在NAS这类设备上跑本地模型的朋友,环境变量和配置往往写在docker-compose或系统面板里,少配一个、配错一个,TS这层检查能让你在启动阶段就看到,而不是半夜爬起来看日志。

5. 迁移实操:从裸JS到TS 5.7的完整过程

5.1 渐进式迁移配置方案

如果项目已经在跑了,别想着推翻重写,渐进迁移是稳妥路线。第一步先建tsconfig,核心配置如下:

{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "allowJs": true, "checkJs": false, "outDir": "dist" }, "include": ["src"] }

allowJs: true意味着你可以保留原有.js文件,用.ts文件逐步替换。新文件直接写成.ts并享受所有严格检查,旧文件暂时不碰,等有需求时再改。checkJs: false是为了不让TS去检查你还没打算动工的JS文件——如果一开始就改成true,历史报错会砸你一脸,容易劝退。

我实际迁移的顺序是:先给最核心的数据边界上类型——解析模型响应、解析工具参数、读取配置这几处;再给工具注册表上类型;最后才是业务逻辑里的细枝末节。这个过程会暴露出一些裸JS时期被掩盖的问题,比如某个变量在某些分支下真的是undefined,只是之前没有触发。

5.2 迁移中比较难缠的几类类型报错

说说个人体会,迁TS时最花时间的不是语法报错,而是三类“业务惯性”问题。

第一类是隐式any。以前裸JS写function foo(obj),变成.ts以后TS要求obj有类型。如果一时不知道确切结构,可以先写Record<string, unknown>顶着,然后尽快找出真实结构替换,不要让any过夜。any一旦泛滥,类型保护就形同虚设。

第二类是“可能为null”的连锁反应。严格模式下,data.choices[0]可能是undefined,msg.content可能是null。这些值一旦流出边界,所有下游消费方都要求判空。处理方法是找到源头,把“不确定结构”统一在边界处收窄,就像前面parseChatResponse那样,出了边界之后不要再靠any绕过。

第三类是和SDK自带类型打架。OpenAI、Anthropic这些官方SDK基本都自带TypeScript类型,如果项目里自己手写了接口定义,就可能出现字段名差异导致类型不兼容。建议少自己定义“雷同类型”,优先import type { ChatCompletion } from 'openai/resources/chat/completions'这种方式,拿SDK的类型作为模型返回类型,再叠加zod做运行时校验。

5.3 哪些AI应用场景不值得上TS

我不敢说所有项目都该迁。如果项目是完全的一次性脚本、临时demo、或者只是为了验证某个prompt效果,裸JS就够了。给这种场景引入TS,花在类型定义上的时间可能比写业务逻辑还多,完全划不来。

还有一种折中方案:不想引入构建步骤,又想要一点类型检查,可以用// @ts-check加JSDoc注释。VSCode会基于JSDoc做一定程度的类型推断,配合checkJs: true,算是“抹一点TS酱的裸JS”。但坦白说,AI应用一旦涉及function calling和复杂消息结构,JSDoc那种写法会变得很啰嗦,撑不了太久。

我的判断标准很简单:如果这个AI应用要活过三个月、要接超过一个模型供应商、会被不止一个人维护,就该上TS。如果只是今晚写个脚本跑一次,裸JS没毛病,没必要为了仪式感引入一堆工具链。

6. 迁移之后我给自己定的一条纪律

迁移完TS 5.7之后,我又给自己加了一条纪律:所有对外部世界不信任的边界——模型返回、工具参数、配置文件——都必须经过“unknown、校验、收窄”这个流程,不允许在边界以外出现as any或者裸断言。这样做以后,TS的类型不再是装饰,而是真实承担了运行时防御的一部分职责。

我常被问到“AI应用开发面试时会不会考类型系统”。我的回答是:类型系统本身不是考点,但它是衡量AI应用代码质量的一个重要指标。面试官看你的AI项目代码,如果满屏都是any和裸断言,第一印象会打折扣;反过来,能在数据边界把类型收得干干净净,说明你对“模型输出不可控”这件事有清醒的认知。

个人感受是,TS 5.7和AI应用组合在一起挺搭的。AI应用开发里,别人帮你兜底的东西少——模型不可控、网络不可控、第三方SDK不可控,你能控制的只有自己这边的代码边界。把边界用类型焊死,少掉的这三个坑,基本等于少熬三个深夜。这就是我这次迁移最大的收获。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询