news 2026/10/30 5:18:19

你以为你懂 Bun + TS?看完这篇实战,才发现以前全写错了

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
你以为你懂 Bun + TS?看完这篇实战,才发现以前全写错了

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.md

package.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 UnauthorizedKey 缺失/错误/过期检查 .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到类型收窄、运行时校验、报错排查、工程化组织,整条链路就闭环了。剩下的就是把它接进你自己的项目,跑起来看结果。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 21:10:28

串口通信可靠性:延时为何致命,平台开发守则与高速选型实战

前一阵在调一块多协议接入板&#xff0c;主控是STM32F407VET6&#xff0c;跑着三个串口、一路RS485和一路TTL屏显。本来以为好好的&#xff0c;结果联调时问题一个接一个&#xff1a;串口发送乱码、上位机超时、编译报重定义……最离谱的是有人为了“让发送稳一点”&#xff0c…

作者头像 李华
网站建设 2026/10/16 0:54:56

公鸡母鸡目标检测识别数据集:1,300张图像 | 目标检测分类识别

公鸡母鸡目标检测识别数据集&#xff1a;1,300张图像 | 目标检测分类识别 源码数据分享 通过网盘分享的文件&#xff1a;公鸡母鸡分类识别数据集 链接: https://pan.baidu.com/s/1oHTa3XsWyQXw81e8D5Phfw?pwd46vq 提取码: 46vq 一、引言&#xff1a;从人工辨识到AI智能识别的…

作者头像 李华
网站建设 2026/10/22 22:29:07

从六款开源RAG产品逆向工程到自研架构蓝图

做了几年RAG项目&#xff0c;从早期拿开源框架拼demo&#xff0c;到后来给企业做私有化知识库&#xff0c;最大的体会是&#xff1a;RAG看起来简单&#xff0c;做到生产可用很难。检索精度、解析质量、响应延迟、可维护性&#xff0c;每一个环节都会在真实场景里给你找麻烦。去…

作者头像 李华