从"问一句答一句",到"LLM 自己循环思考",再到"调用你的工具"
你后面 90% 的困惑,都源于分不清"名字是谁定的"。看代码前先背下这三条:
model、messages、role、content、tools、tool_calls、tool_call_id、id、function、name、arguments……一个都不能改,改了服务器直接不认。来源:OpenAI Chat Completions API 文档(火山方舟 / DeepSeek 都兼容它)。parameters 内部用的是 JSON Schema 标准——type、properties、required。同样不能改。来源:JSON Schema 规范(不专属任何一家 AI 厂商)。properties 里的参数名(如 city)都是自由的。TS 的职责是帮你检查拼写一致,不是规定名字。代价是:这些名字必须在你自己的代码里互相匹配,拼错一个编译期就能查出来。JSON.stringify 原样发给服务器、或从服务器响应里读出来"的键名 → 都是协议字段,不能改。凡是只存在于你自己的 TS 文件里、不直接上网络的 → 基本都能改。这条判断标准贯穿三章。目标:搭建一个能跑的最小项目——给模型一句话,拿回一段文本。
一个 Node 项目只需要两个文件就能"被 TypeScript 管理":package.json(告诉 npm 项目叫什么、依赖什么、怎么跑)和 tsconfig.json(告诉 TypeScript 检查规则)。
package.json 里的 scripts 把"怎么启动程序"变成一句 npm run xxx,省得每次敲一长串 node --env-file=.env src/...ts;② tsconfig.json 开的 strict 和 erasableSyntaxOnly 直接决定了"你的代码会不会在类型检查期被抓出错误"——对新手,严格模式是最好的老师。先看 package.json(下方是带注释的讲解版,JSONC 格式)
然后是 tsconfig.json(同样带注释)
.ts(Node 22.18+ 自带"类型剥离")。为了让这一点成立,必须开 erasableSyntaxOnly(禁止 enum、参数属性等需要编译才能跑的语法)+ noEmit(只检查不输出)+ allowImportingTsExtensions(允许带 .ts 后缀的 import)。三者缺一,要么跑不起来,要么检查不了。最后是密钥文件。新建 .env.example(复制成 .env 后填入真实 Key)和 .gitignore:
src/llm.ts核心是一个函数 askLLM(question) → 模型回复文本。整个第 2、3 步都是围绕这个函数升级的,所以它的注释最详细:
.env 或 Key 填错 → 401;② 模型 ID 没在控制台开通 → 404 Model Not Found;③ model 的值不能照抄教程,要换成你账号里有的模型 ID。src/index.ts
目标:模型不再"问一句答一句",而是分步骤思考、每轮累积之前的回答,直到它认为完成。
question 是"一句话";这一步要理解:LLM 是无状态的——它不记得上次说了什么。所以"对话"必须由你维护:每次请求都带上完整历史(messages 数组)。Agent 的本质就是一个循环:发历史 → 模型回复 → 判断是否完成 → 没完成就把回复累积进历史 → 再发……。你的"记忆"就是那个数组本身。src/types.ts:定义 Message
role、content 是协议字段(不能改);role 的值("user" 等)也是协议定的。Message 这个 interface 名是你自己起的。TS 在这里的作用:当你写 { role: "boss" } 时,编译期直接报错——这就是"TS 检查拼写"的体现。src/llm.ts:askLLM(question) → askLLM(messages)只改两处:参数从单个问题变成整个历史;body 里直接放 messages。
src/agent.ts:主循环
package.json:加 agent 入口
npm run typecheck 通过;npm run agent 输出多轮 Step 1 / Step 2 / ...,最后出现 任务完成。目标:模型在循环中不仅能"说话",还能"提出调用你的工具",你执行工具、把真实结果喂回给它,它再决定下一步。
tools 数组)发给模型,告诉它"你能干什么、参数长什么样";厨房(executeTool)由你执行。模型只"提出请求",执行权永远在你手里——这既是安全(模型不能直接跑你的代码),也是职责分离(改菜单不影响厨房)。src/types.ts:新增 RawToolCall,role 加 "tool"
src/tools.ts:工具说明书 + executeTool
type/properties/required 是 [JSON Schema](通用标准)定的;而它们外面的 type: "function"、function.name 是 [协议] 定的。初学者最容易混的就是这两层——记住:最外层是 OpenAI 兼容协议,parameters 里面是 JSON Schema 标准。至于 city、expression 这些参数名,是你自己起的(但要和 executeTool 里一致)。required: ["city"] 里的 "city" 必须和 properties 里的 city 拼写一致(这是你自己的约定,错了类型检查查不出来,模型会传不出参数);② schema 的 name 与 executeTool 的 case 必须一致——不一致时工具会静默失效,模型收到"未知工具"。src/llm.ts:返回值升级为 LLMResponse,支持传 tools
content 有值);② 提出调用工具(tool_calls 有值,content 可能为 null)。一个返回值装不下两种信息,所以升级成 LLMResponse 对象。这也是"接口形状"设计的常见思路:一次通信可能返回多种结果时,用对象 + 可选字段承载。src/agent.ts:主循环里处理工具调用
arguments → 把字符串当对象用;③ tool_call_id 填错 → 模型无法关联结果,逻辑错乱;④ 工具名(schema 的 name)与 executeTool 的 case 不一致 → 返回"未知工具"。
编程时对照这张表判断:一个名字到底能不能改、该不该保持一致。
| 字段 / 值 | 能不能改 | 谁规定的 | 说明 |
|---|---|---|---|
model 的值(如 deepseek-v4-flash-ga-260731) | 不能改(换模型要换成你账号开通的 ID) | 火山方舟控制台 | 值决定用哪个模型;没开通会 404 |
messages / role / content | 键名不能改 | OpenAI 兼容协议 | 请求体最外层结构 |
role 的值:"user" / "assistant" / "system" / "tool" | 值不能改 | OpenAI 兼容协议 | 服务器只认这四个角色 |
tools / type / function / name / description / parameters | 键名不能改 | OpenAI 兼容协议 | 工具菜单结构,一层都不能少 |
type 的值:"function" | 值不能改 | OpenAI 兼容协议 | 目前函数调用只有这一个合法值 |
parameters 里的 type / properties / required | 键名不能改 | JSON Schema 标准 | 与 OpenAI 无关的通用规范 |
properties 里的参数名(city、expression) | 自己起 | 你自己 | 但必须和 executeTool 里 args.xxx 拼写一致 |
响应 choices[0].message.content / tool_calls | 键名不能改 | OpenAI 兼容协议 | 读回复时按这个路径取 |
响应的 tool_calls[].id / function.name / function.arguments | 键名不能改 | OpenAI 兼容协议 | arguments 是 JSON 字符串,要 JSON.parse |
回填的 tool_call_id | 键名不能改 | OpenAI 兼容协议 | 下划线写法,不是 toolCallId |
| 你自己的接口名 / 函数名 / 变量名 / import 路径 | 随便起 | TypeScript / 你 | 但项目内必须互相一致,靠 tsc 检查 |
ARK_API_KEY、MAX_STEPS、[DONE] | 随便起 | 你自己 | 自己发明的约定,改时注意所有引用处 |
tsc)能帮你抓住"拼写不一致",但抓不住"该用协议名却写错协议名"——所以背熟上面这张表。