TS5.7 vs 裸JS:AI应用少3坑

0 阅读6分钟

凌晨 2 点,线上 AI 客服突然开始把“退款”工具调用成“查物流”。
不是模型变蠢了,是我们前端把 tool schema 改了,后端还是旧字段,TypeScript 没拦住。
后来我把这套链路迁到 TypeScript 5.7 + 结构化输出 + 流式消费,同样功能,线上问题少了一大截。

这篇不讲“AI 应用开发的宏大趋势”,只讲一个很具体的实战问题:如何用 TypeScript 5.7 把一个 AI Chat 应用写得更稳、更可维护、更不容易半夜报警。

先说结论:AI 应用最怕的不是模型,而是边界

很多团队第一次做 AI 应用,会把注意力放在模型选择上:

  • GPT 还是 Claude?
  • 要不要接国产大模型?
  • prompt 怎么写得更玄学?
  • RAG 要不要上向量库?

这些都重要,但我踩下来发现,真正高频出问题的是三类边界:

问题裸 JS 常见写法TS 5.7 推荐写法后果差异
模型返回结构不稳定JSON.parse() 后直接用类型收窄 + schema 校验少很多 undefined 报错
Tool Calling 字段漂移prompt 里约定字段satisfies 固定工具协议改字段时编译期暴露
流式响应状态混乱字符串拼接可判别联合类型建模UI 不容易错乱

AI 应用开发的核心,不是“相信模型”,而是“限制模型的自由度”。

这也是 TypeScript 5.7 在 2026 年仍然值得认真用的原因:它不能让模型更聪明,但能让你知道哪里不该信模型。

项目骨架:别一上来就塞进 React

我建议先把 AI 调用层独立出来,不要直接写在组件里。

示例目录:

src/
  ai/
    client.ts
    types.ts
    tools.ts
    stream.ts
  app/
    chat.tsx
tsconfig.json

tsconfig.json 里我会打开这些配置:

{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "rewriteRelativeImportExtensions": true
  }
}

这里有个容易被忽略的点:TypeScript 5.7 的 rewriteRelativeImportExtensions 对 AI 应用很实用。

当你在源码里写:

import { runAgent } from "./client.ts";

编译后可以重写为运行时可识别的 .js 路径,减少 Node ESM 项目里“本地能跑、部署炸掉”的问题。AI 应用通常会同时跑在 Node、Serverless、边缘函数里,模块解析问题非常烦,这个配置能少掉一类低级故障。

第 1 坑:模型返回 JSON,不等于可信 JSON

很多教程会这么写:

const data = JSON.parse(content);
return data.answer;

这在 demo 里没问题,上线后迟早出事。模型可能返回:

{
  "anwser": "拼错字段",
  "confidence": "high"
}

注意,confidence 可能是字符串,字段还可能拼错。

我们先定义结构:

export type AiAnswer = {
  answer: string;
  confidence: number;
  sources?: string[];
};

export function isAiAnswer(value: unknown): value is AiAnswer {
  if (typeof value !== "object" || value === null) return false;

  const v = value as Record<string, unknown>;

  return (
    typeof v.answer === "string" &&
    typeof v.confidence === "number" &&
    (v.sources === undefined ||
      (Array.isArray(v.sources) && v.sources.every(i => typeof i === "string")))
  );
}

调用时不要偷懒:

const raw = await response.text();

let parsed: unknown;
try {
  parsed = JSON.parse(raw);
} catch {
  throw new Error("AI 返回的不是合法 JSON");
}

if (!isAiAnswer(parsed)) {
  throw new Error("AI 返回结构不符合 AiAnswer");
}

return parsed.answer;

有人会说:这不是很啰嗦吗?

是的,但这段代码换来的收益是:模型胡说八道时,错误停在边界层,而不是污染整个业务层。

更进一步,你可以用 Zod、Valibot、ArkType 做 runtime schema。本文不用库,是为了让你看清楚本质:AI 返回值必须从 unknown 开始,而不是从 any 开始。

第 2 坑:Tool Calling 不是字符串约定,是协议

AI 应用一旦进入业务系统,基本都会接工具:

  • 查订单
  • 退订阅
  • 发优惠券
  • 创建工单
  • 查询知识库

最危险的写法是把工具参数写在 prompt 里:

你可以调用 refundOrder,参数是 orderId 和 reason

然后代码里相信模型一定会传:

tool.args.orderId

更稳的方式是把工具定义成 TypeScript 协议。

type ToolName = "getOrder" | "refundOrder";

type ToolMap = {
  getOrder: {
    input: { orderId: string };
    output: { status: "paid" | "shipped" | "refunded" };
  };
  refundOrder: {
    input: { orderId: string; reason: string };
    output: { success: boolean; ticketId: string };
  };
};

type ToolCall<T extends ToolName = ToolName> = {
  name: T;
  input: ToolMap[T]["input"];
};

const tools = {
  getOrder: async (input: ToolMap["getOrder"]["input"]) => {
    return { status: "paid" } satisfies ToolMap["getOrder"]["output"];
  },

  refundOrder: async (input: ToolMap["refundOrder"]["input"]) => {
    return {
      success: true,
      ticketId: `T-${input.orderId}`
    } satisfies ToolMap["refundOrder"]["output"];
  }
};

这里的 satisfies 很关键。它不会粗暴地把类型断言过去,而是检查对象是否满足目标类型。

比如你手滑写成:

return { ok: true };

TypeScript 会直接报错。

这类问题如果靠人工测试发现,成本很高;如果靠用户发现,代价更高。

第 3 坑:流式响应别只当字符串处理

AI Chat 应用为了体验,基本都会做 streaming。很多人会写:

let text = "";

for await (const chunk of stream) {
  text += chunk;
  setMessage(text);
}

这能跑,但很快会遇到问题:

  • 模型一边输出文本,一边请求工具调用
  • 工具执行完成后又继续输出
  • 用户中途取消
  • 网络断开后要恢复状态

这时应该把流事件建模,而不是拼字符串。

type AiStreamEvent =
  | { type: "text.delta"; text: string }
  | { type: "tool.call"; call: ToolCall }
  | { type: "tool.result"; name: ToolName; result: unknown }
  | { type: "done" }
  | { type: "error"; message: string };

function reduceMessage(state: string, event: AiStreamEvent): string {
  switch (event.type) {
    case "text.delta":
      return state + event.text;

    case "tool.call":
      return state + `\n\n正在调用工具:${event.call.name}...`;

    case "tool.result":
      return state + `\n工具执行完成:${event.name}`;

    case "done":
      return state;

    case "error":
      return state + `\n错误:${event.message}`;

    default: {
      const _exhaustive: never = event;
      return _exhaustive;
    }
  }
}

最后这个 never 检查非常有用。

以后你新增一个事件:

| { type: "rate.limit"; retryAfter: number }

如果忘了处理,编译器会提醒你。这就是 TypeScript 在 AI 应用里的价值:把“不确定的模型行为”包进“确定的工程协议”。

一个最小可用的 AI 调用层

下面是一个简化版,不绑定具体厂商。你可以接 OpenAI Responses API、Anthropic Messages API,或者内部网关。

type ChatMessage = {
  role: "system" | "user" | "assistant";
  content: string;
};

type ChatOptions = {
  messages: ChatMessage[];
  signal?: AbortSignal;
};

export async function createAiResponse(options: ChatOptions) {
  const res = await fetch("https://api.example.com/v1/responses", {
    method: "POST",
    signal: options.signal,
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.AI_API_KEY}`
    },
    body: JSON.stringify({
      model: "gpt-4.1-mini",
      input: options.messages,
      stream: true
    })
  });

  if (!res.ok) {
    throw new Error(`AI 请求失败:${res.status}`);
  }

  if (!res.body) {
    throw new Error("当前运行环境不支持 ReadableStream");
  }

  return res.body;
}

这段代码有几个细节:

  1. signal?: AbortSignal:用户关闭页面、切换会话时可以取消请求。
  2. res.ok 必须判断:不要把 429、500 当正常流处理。
  3. res.body 必须判断:不同运行时对 Web Stream 支持不完全一致。
  4. process.env.AI_API_KEY 不要出现在浏览器端:前端必须走后端或边缘函数代理。

尤其最后一点,很多 AI 应用的第一个安全事故,就是把 API Key 打进了前端包。

TS 5.7 在这里到底赢在哪?

不是说 TypeScript 5.7 有什么“AI 专属语法”。它真正解决的是 AI 应用里几个工程痛点。

场景没有 TS 的体验TS 5.7 工程化收益
模型输出运行时才知道错unknown + 类型守卫先拦截
工具调用字段靠约定ToolMap 统一输入输出
流式 UI状态越来越乱联合类型表达事件流
ESM 部署import 路径踩坑rewriteRelativeImportExtensions 降低部署风险
复杂重构改完靠测试兜底编译期暴露破坏性改动

我的经验是:AI 应用越接近业务系统,TypeScript 的收益越大。

如果只是做一个问答 demo,JS 当然够了;但只要你开始接订单、支付、权限、工单,类型系统就不是“洁癖”,而是刹车。

实战建议:别让 prompt 成为唯一契约

最后给几个我在项目里会坚持的规则:

  1. 模型输入输出都从边界层收口
    不要在组件、service、controller 到处散落 prompt 和解析逻辑。

  2. 所有工具调用必须有类型定义
    prompt 可以描述工具,但不能替代工具协议。

  3. AI 返回值默认不可信
    哪怕你要求 JSON mode,也要做 runtime 校验。

  4. 流式事件必须结构化
    不要只维护一个不断拼接的字符串。

  5. TypeScript 配置要严格
    strict、noUncheckedIndexedAccess、exactOptionalPropertyTypes 能提前暴露很多问题。

这套做法看起来比 demo 慢,但进入多人协作后会非常爽:改字段、加工具、换模型、迁移运行时,很多风险会在编译期冒出来。

我甚至有个有争议的判断:2026 年还在用“prompt + any + JSON.parse”写业务级 AI 应用,和裸奔差不多。

你们项目里的 AI 调用层是怎么设计的?是先上类型协议,还是先让模型跑起来再慢慢补工程化?