1. 从1 + "2"说起:Bun + TypeScript 工程化到底解决什么问题
如果你写过 JavaScript,大概率被1 + "2"这种表达式坑过。控制台打印出"12"而不是3,代码不报错,逻辑却已经错了。更隐蔽的场景是表单:<input type="number">拿到的value永远是字符串,age + 1直接变成字符串拼接,用户填 18,下一页显示 181。这类问题在纯 JS 项目里靠人肉 review 很难兜住,一旦项目里再接入 LLM 调用、流式响应、工具函数回调,参数类型错一位,报错信息能让你查一晚上。
这篇要聊的不是「TypeScript 是什么」这种入门概念,而是把Bun 运行时 + TypeScript 静态类型 + 运行时校验三件事串成一个可复用的小工具:一个能调用 LLM 接口、带类型约束、带错误兜底的命令行脚本。适合已经会写 JS、但每次配tsconfig.json和ts-node就头疼的人;也适合想把 Bun 真正用进日常开发、而不是只跑个hello world的人。
核心检索词先摆出来:Bun 运行 TypeScript、bunfig.toml 配置、tsconfig 类型收窄、LLM API 调用封装。这几个词基本覆盖了从环境到落地的全链路。我试过用 Node + ts-node 跑同样的脚本,光启动就要等一两秒,换成bun run index.ts基本是瞬间出结果,这个差距在频繁调试时非常明显。
下面按「问题场景 → 环境准备 → 可复制配置 → 验证请求 → 报错排查 → 后续接入」的顺序展开,每一段都给能直接抄的代码和配置,不玩虚的。
2. Bun 前置准备:bun init 与 tsconfig.json 到底该怎么配
先说结论:Bun 对 TypeScript 是一等支持,bun run index.ts会实时编译并缓存,不需要ts-node、不需要nodemon、不需要预编译。但这不代表tsconfig.json可以乱写——编辑器(VS Code)的类型提示、路径别名、严格模式,全靠它。配错了,运行时没事,写代码时满屏红波浪线,体验极差。
2.1 初始化项目与目录结构
先建目录、初始化。Bun 的bun init会交互式问你项目类型,直接选Blank就行:
mkdir bun-llm-tool && cd bun-llm-tool bun init生成的结构大概是这样:
bun-llm-tool/ ├── index.ts ├── package.json ├── tsconfig.json └── README.mdpackage.json里会有"module": "index.ts"和"type": "module",说明 Bun 默认按 ESM 处理。这一点和 Node 不同,Node 里你要么写.mjs,要么在package.json里声明"type": "module",Bun 直接帮你省了。
2.2 tsconfig.json:严格模式 + 类型收窄的关键项
Bun 生成的默认tsconfig.json比较宽松,建议改成下面这份。重点是strict、noUncheckedIndexedAccess、moduleResolution这几项,它们直接决定你能不能写出「类型收窄」的代码:
{ "compilerOptions": { "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "moduleDetection": "force", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "skipLibCheck": true, "types": ["bun-types"], "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules", "dist"] }几个参数解释一下,都是踩过坑的:
moduleResolution: "bundler"是 Bun 官方推荐值,允许你写import { x } from "./utils"而不带扩展名,同时支持paths别名。noUncheckedIndexedAccess打开后,arr[0]的类型会变成T | undefined,逼你在访问数组元素前做判断——这在解析 LLM 返回的choices[0]时特别有用,能提前拦住「响应结构不对导致 undefined」的问题。types: ["bun-types"]让编辑器认识Bun.file、Bun.serve这些全局 API。
2.3 bunfig.toml:安装源与运行时的可复制配置
bunfig.toml是 Bun 的配置文件,放在项目根目录。它控制包安装行为、运行时参数、测试配置。下面这份是日常开发够用的版本:
[install] # 安装时自动保存到 package.json save = true # 精确版本,避免 ^ 带来的意外升级 exact = false # 可选:指定安装源(按需替换为你自己的镜像地址) # registry = "https://registry.npmjs.org" [install.cache] # 开启全局缓存,重复安装秒级完成 disable = false dir = "~/.bun/install/cache" [run] # 运行 .ts 文件时自动加载 .env env = true [test] # 测试时也加载环境变量 preload = ["./test/setup.ts"][run] env = true这一项很关键:它让bun run自动读取项目根目录的.env,不用再手动dotenv.config()。当然,如果你更习惯显式加载,保留dotenv也行,两种方式不冲突。
2.4 安装依赖:bun add 与 npm 的差异
装依赖用bun add,速度比npm install快很多,因为它走全局二进制缓存 + 硬链接,不会每个项目都复制一份:
bun add axios bun add -d @types/node注意axios这类库在 Bun 里可以直接跑,Bun 兼容大部分 Node API。如果你追求更轻量,其实可以用 Bun 内置的fetch替代 axios,省一个依赖。后面实战部分我会给两种写法。
3. 可复制配置:类型收窄 + 运行时校验的完整脚本
这一节是核心。光有tsconfig.json不够,因为 TypeScript 的类型只在编译期存在,运行时拿到的 JSON 依然是any。LLM 接口返回的choices、message、content这些字段,如果只靠as断言,线上照样崩。正确做法是:编译期用类型收窄,运行时用校验函数兜底。
3.1 定义类型与类型收窄
先建src/types.ts,把 LLM 响应的结构定义清楚:
// src/types.ts export interface ChatMessage { role: "system" | "user" | "assistant"; content: string; } export interface ChatRequest { model: string; messages: ChatMessage[]; temperature?: number; stream?: boolean; } export interface ChatChoice { index: number; message: ChatMessage; finish_reason: string; } export interface ChatResponse { id: string; object: string; created: number; model: string; choices: ChatChoice[]; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; }类型收窄的典型用法在这里:role用字面量联合类型,传"userr"直接编译报错。choices是数组,配合noUncheckedIndexedAccess,res.choices[0]的类型是ChatChoice | undefined,你必须先判断再取值。
3.2 运行时校验函数
编译期类型管不住网络返回,写一个轻量校验:
// src/validate.ts import type { ChatResponse } from "./types"; export function isChatResponse(data: unknown): data is ChatResponse { if (typeof data !== "object" || data === null) return false; const obj = data as Record<string, unknown>; if (!Array.isArray(obj.choices)) return false; const first = obj.choices[0]; if (typeof first !== "object" || first === null) return false; const choice = first as Record<string, unknown>; const message = choice.message; if (typeof message !== "object" || message === null) return false; return typeof (message as Record<string, unknown>).content === "string"; }这个函数返回data is ChatResponse,调用后 TypeScript 会自动把data收窄成ChatResponse,后面访问data.choices[0].message.content就不会报错。这就是「类型守卫」的价值:运行时判断 + 编译期收窄,一步到位。
3.3 主脚本入口 index.ts
把请求逻辑写进src/index.ts,用 Bun 内置fetch,不依赖 axios:
// src/index.ts import type { ChatRequest } from "./types"; import { isChatResponse } from "./validate"; const BASE_URL = process.env.LLM_BASE_URL ?? "https://taotoken.net/api"; const API_KEY = process.env.LLM_API_KEY; const MODEL_ID = process.env.LLM_MODEL_ID ?? "claude-sonnet-4-5"; if (!API_KEY) { console.error("缺少 LLM_API_KEY,请在 .env 中配置"); process.exit(1); } async function chat(prompt: string): Promise<string> { const body: ChatRequest = { model: MODEL_ID, messages: [{ role: "user", content: prompt }], temperature: 0.7, }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 20000); try { const res = await fetch(`${BASE_URL}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify(body), signal: controller.signal, }); if (!res.ok) { const text = await res.text(); throw new Error(`HTTP ${res.status}: ${text}`); } const data: unknown = await res.json(); if (!isChatResponse(data)) { throw new Error("响应结构不符合预期"); } const choice = data.choices[0]; if (!choice) { throw new Error("choices 为空"); } return choice.message.content; } finally { clearTimeout(timer); } } const answer = await chat("用一句话解释 Bun 和 Node 的区别"); console.log("模型返回:", answer);注意几个细节:AbortController做超时控制,20 秒没响应自动中断,避免进程卡死;res.ok判断 HTTP 状态码,非 2xx 直接抛错并带上原始文本;isChatResponse做结构校验,防止字段缺失导致后续undefined。
3.4 .env 与运行
根目录建.env:
LLM_BASE_URL=https://taotoken.net/api LLM_API_KEY=你的Key LLM_MODEL_ID=claude-sonnet-4-5因为bunfig.toml里开了[run] env = true,直接跑:
bun run src/index.ts如果你需要申请 Key 或查看可用模型,可以走这个入口:API Keys 管理,模型列表和接入文档在 接入文档。
4. 验证请求:bun run 成功结果与响应结构确认
配置写完,跑一遍看结果。正常输出应该类似:
模型返回: Bun 使用 JavaScriptCore 引擎和 Zig 编写,启动更快、内置包管理器和 TS 支持,而 Node 基于 V8 且需要额外工具链。如果只看到这一行,说明链路通了。但别急着收工,建议再做三件事确认稳定性。
4.1 打印完整响应结构
临时在chat函数里加一行console.log(JSON.stringify(data, null, 2)),看看真实返回长什么样。不同模型的字段可能略有差异,比如有的返回usage,有的不返回。确认choices[0].message.content确实存在,再删掉这行。
4.2 用 Bun 内置测试跑一遍
Bun 自带测试器,写个简单用例验证校验函数:
// test/validate.test.ts import { test, expect } from "bun:test"; import { isChatResponse } from "../src/validate"; test("合法响应通过校验", () => { const data = { id: "1", object: "chat.completion", created: 1, model: "test", choices: [{ index: 0, message: { role: "assistant", content: "hi" }, finish_reason: "stop" }], }; expect(isChatResponse(data)).toBe(true); }); test("缺少 choices 被拦截", () => { expect(isChatResponse({ id: "1" })).toBe(false); });跑bun test,两个用例都过,说明校验逻辑没问题。
4.3 验证类型收窄是否生效
在编辑器里把isChatResponse(data)那行注释掉,再访问data.choices[0].message.content,VS Code 应该立刻报红。恢复后红波浪线消失。这一步是确认tsconfig.json的strict和类型守卫真的在工作,不是摆设。
4.4 性能对比感受
同样的脚本,用time bun run src/index.ts和time npx ts-node src/index.ts各跑一次。Bun 的启动时间通常在几十毫秒级,ts-node 往往要 1 秒以上。这个差距在写脚本、跑定时任务时体感非常明显。
5. 常见报错排查:401、local proxy failed、reading choices 对照表
这一节按真实报错整理,遇到问题直接对号入座。
5.1 401 Unauthorized
最常见。原因通常是 Key 没读到或格式不对。检查.env里LLM_API_KEY是否有多余空格、引号;确认bunfig.toml的[run] env = true生效;如果手动用dotenv,确认dotenv.config()在读取process.env之前调用。还有一种情况是 Key 已过期或被禁用,去控制台重新生成一个。
5.2 local proxy failed / 连接被拒绝
这个报错通常出现在请求地址写错、端口不对,或者本机网络环境有额外限制时。先确认LLM_BASE_URL拼写正确,不要多写或少写/v1。用curl单独测一下地址连通性:
curl -X POST "$LLM_BASE_URL/v1/chat/completions" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $LLM_API_KEY" \ -d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}]}'如果 curl 也失败,问题在地址或 Key;如果 curl 成功但脚本失败,问题在代码。
5.3 reading 'choices' / Cannot read properties of undefined
典型的结构假设错误。res.data.choices为undefined,说明返回体根本不是预期格式。可能是接口返回了错误对象(比如{"error": {"message": "..."}}),也可能是模型名写错导致 400。解决方式:先打印原始响应文本,再判断。前面写的isChatResponse就是专门拦这个的。
5.4 OAuth / 认证方式不匹配
有些接口要求Authorization: Bearer,有些要求x-api-key,还有的走 OAuth 流程。如果你用的是 Anthropic 风格接口,注意 header 名称。TaoToken 的接口兼容 OpenAI 格式,用Bearer即可。如果混用了不同厂商的 SDK,容易出现认证头冲突。
5.5 报错对照表
| 报错信息 | 可能原因 | 处理方式 |
|---|---|---|
| 401 Unauthorized | Key 缺失/错误/过期 | 检查 .env,重新生成 Key |
| local proxy failed | 地址错误或网络限制 | 用 curl 验证地址连通性 |
| reading 'choices' | 响应结构不符预期 | 打印原始响应,加类型守卫 |
| OAuth 相关报错 | 认证头方式不匹配 | 确认用 Bearer 还是 x-api-key |
| 请求一直 pending | 未设超时 | 加 AbortController 或 timeout |
| 模型名 400 | 模型 ID 不存在或无权限 | 查文档确认可用模型 ID |
5.6 三件套检查清单
无论用哪种方式接入,Base URL、Key、Model ID 这三样必须同时正确。Base URL 用https://taotoken.net/api,Key 从控制台获取,Model ID 按文档填。任何一项错了都会报错,且报错信息不一定直白。建议把这三项写进.env,不要硬编码在代码里。
6. 从脚本到工程:把 Bun + TS 用进日常开发
脚本跑通只是起点。真正把 Bun + TypeScript 用进日常,还要考虑几件事:怎么组织多文件、怎么处理流式响应、怎么接入编辑器或 Agent 工具。
6.1 多文件组织与路径别名
项目变大后,import路径会变得很长。用tsconfig.json里的paths配别名:
"paths": { "@/*": ["./src/*"] }然后import { chat } from "@/chat"。注意 Bun 运行时也认这个别名,不需要额外插件,这是它比 Node + ts-node 省心的地方。
6.2 流式响应处理
LLM 接口支持stream: true,返回 SSE 格式。Bun 的fetch响应体是ReadableStream,可以这样读:
const res = await fetch(url, { ... }); const reader = res.body?.getReader(); const decoder = new TextDecoder(); while (reader) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); process.stdout.write(chunk); }流式场景下类型校验要放宽,因为每个 chunk 是半截 JSON,不能整体解析。建议按行拆分,遇到data:前缀再解析。
6.3 接入编辑器与 Agent 工具
如果你用 Claude Code、Cline 这类工具,配置方式类似:填 Base URL、Key、Model ID。以 Claude Code 为例,需要设置ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY,模型 ID 按文档填。Cline 的 MCP 配置里同样三项。Codex 的auth.json也是 Base URL + Key + Model ID 三件套。配置入口可以参考 Claude Code 接入说明。
6.4 长期编码与 Agent 场景
如果只是偶尔调一次接口,按量付费够用。但如果要跑长期编码任务、Agent 循环调用,建议了解 Coding Plan,成本更可控。模型对话调试可以用 模型对话 快速验证 prompt 效果,确认没问题再写进代码。
6.5 一个实用技巧
把chat函数抽成独立模块,加一个重试包装:
async function chatWithRetry(prompt: string, retries = 3): Promise<string> { for (let i = 0; i < retries; i++) { try { return await chat(prompt); } catch (err) { if (i === retries - 1) throw err; await new Promise((r) => setTimeout(r, 1000 * (i + 1))); } } throw new Error("unreachable"); }网络抖动时自动重试,指数退避,比裸调用稳得多。这个模式在 Agent 场景里几乎是标配。
到这里,从bun init到类型收窄、运行时校验、报错排查、工程化组织,整条链路就闭环了。剩下的就是把它接进你自己的项目,跑起来看结果。