凌晨 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;
}
这段代码有几个细节:
signal?: AbortSignal:用户关闭页面、切换会话时可以取消请求。res.ok必须判断:不要把 429、500 当正常流处理。res.body必须判断:不同运行时对 Web Stream 支持不完全一致。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 成为唯一契约
最后给几个我在项目里会坚持的规则:
-
模型输入输出都从边界层收口
不要在组件、service、controller 到处散落 prompt 和解析逻辑。 -
所有工具调用必须有类型定义
prompt 可以描述工具,但不能替代工具协议。 -
AI 返回值默认不可信
哪怕你要求 JSON mode,也要做 runtime 校验。 -
流式事件必须结构化
不要只维护一个不断拼接的字符串。 -
TypeScript 配置要严格
strict、noUncheckedIndexedAccess、exactOptionalPropertyTypes能提前暴露很多问题。
这套做法看起来比 demo 慢,但进入多人协作后会非常爽:改字段、加工具、换模型、迁移运行时,很多风险会在编译期冒出来。
我甚至有个有争议的判断:2026 年还在用“prompt + any + JSON.parse”写业务级 AI 应用,和裸奔差不多。
你们项目里的 AI 调用层是怎么设计的?是先上类型协议,还是先让模型跑起来再慢慢补工程化?