三步写出你的第一个 Agent

从"问一句答一句",到"LLM 自己循环思考",再到"调用你的工具"

配套可运行项目:f:\myagent | 环境:Node ≥ 22.19 + 火山方舟 API Key

先记住贯穿全程的三条规则

你后面 90% 的困惑,都源于分不清"名字是谁定的"。看代码前先背下这三条:

三条规则怎么判断? 凡是"最终会被 JSON.stringify 原样发给服务器、或从服务器响应里读出来"的键名 → 都是协议字段,不能改。凡是只存在于你自己的 TS 文件里、不直接上网络的 → 基本都能改。这条判断标准贯穿三章。
1

最小 LLM 调用(提问 → 一次回复)

目标:搭建一个能跑的最小项目——给模型一句话,拿回一段文本。

1.1 搭建环境

一个 Node 项目只需要两个文件就能"被 TypeScript 管理":package.json(告诉 npm 项目叫什么、依赖什么、怎么跑)和 tsconfig.json(告诉 TypeScript 检查规则)。

为什么需要这两份文件?package.json 里的 scripts 把"怎么启动程序"变成一句 npm run xxx,省得每次敲一长串 node --env-file=.env src/...ts;② tsconfig.json 开的 stricterasableSyntaxOnly 直接决定了"你的代码会不会在类型检查期被抓出错误"——对新手,严格模式是最好的老师。

先看 package.json(下方是带注释的讲解版,JSONC 格式)

package.json(讲解版)

然后是 tsconfig.json(同样带注释)

tsconfig.json(讲解版)
两个最重要的选项(决定"能不能直接跑 .ts") 本教程不编译,直接用 Node 跑 .ts(Node 22.18+ 自带"类型剥离")。为了让这一点成立,必须开 erasableSyntaxOnly(禁止 enum、参数属性等需要编译才能跑的语法)+ noEmit(只检查不输出)+ allowImportingTsExtensions(允许带 .ts 后缀的 import)。三者缺一,要么跑不起来,要么检查不了。

最后是密钥文件。新建 .env.example(复制成 .env 后填入真实 Key)和 .gitignore

.env.example
.gitignore

1.2 最小 LLM 调用:src/llm.ts

核心是一个函数 askLLM(question) → 模型回复文本。整个第 2、3 步都是围绕这个函数升级的,所以它的注释最详细:

src/llm.ts
为什么这么设计?askLLM 返回文本而不是自己打印:函数只负责"拿答案"。打印是入口的事。到第 2 步你会看到,agent 要拿这个文本做判断(检查有没有 [DONE])——如果函数自己打印了,调用方就拿不到文本了。② 模块顶层读 Key + 直接退出:让错误"当场暴露",而不是等网络请求失败后 401 再排查。③ 用 fetch 不装 axios:Node 18+ 内置,零依赖,少一个坑。
常见坑 ① 忘了建 .env 或 Key 填错 → 401;② 模型 ID 没在控制台开通 → 404 Model Not Found;③ model不能照抄教程,要换成你账号里有的模型 ID。

1.3 入口文件:src/index.ts

src/index.ts

1.4 运行验收

终端 · 运行命令
2

Agent 主循环(LLM 自己循环多次后回复)

目标:模型不再"问一句答一句",而是分步骤思考、每轮累积之前的回答,直到它认为完成。

这一步的心智转变(最重要) 第 1 步里 question 是"一句话";这一步要理解:LLM 是无状态的——它不记得上次说了什么。所以"对话"必须由你维护:每次请求都带上完整历史messages 数组)。Agent 的本质就是一个循环:发历史 → 模型回复 → 判断是否完成 → 没完成就把回复累积进历史 → 再发……。你的"记忆"就是那个数组本身。

2.1 新建 src/types.ts:定义 Message

src/types.ts
这里的名字是谁定的? rolecontent协议字段(不能改);role"user" 等)也是协议定的。Message 这个 interface 名是你自己起的。TS 在这里的作用:当你写 { role: "boss" } 时,编译期直接报错——这就是"TS 检查拼写"的体现。

2.2 升级 src/llm.tsaskLLM(question)askLLM(messages)

只改两处:参数从单个问题变成整个历史;body 里直接放 messages

src/llm.ts(升级)
升级前:src/llm.ts(task 1)

2.3 新建 src/agent.ts:主循环

src/agent.ts
为什么这样设计?DONE 标记 vs 单纯循环 N 次:让模型自己决定"何时完成",比固定次数灵活——任务简单一轮就停,复杂就多轮;② MAX_STEPS 兜底:模型可能不听话,必须有护栏,否则无限循环烧钱;③ 每轮 push 两条(assistant 回复 + user 催促):既满足角色交替的惯例,又用明确指令驱动模型产出下一步;④ 不 push 的话模型会"原地踏步"——每次都从零开始,重复第一步。
常见坑 ① 忘了把回复 push 回历史 → 模型每轮都答同一句话;② MAX_STEPS 设太小 → 任务没跑完就结束;③ 期待模型"记得"你上次说了什么——它不记得,一切靠 messages。

2.4 修改 package.json:加 agent 入口

package.json(只展示 scripts 段)
验收 npm run typecheck 通过;npm run agent 输出多轮 Step 1 / Step 2 / ...,最后出现 任务完成
3

工具调用(LLM 循环 + 可调用工具后回复)

目标:模型在循环中不仅能"说话",还能"提出调用你的工具",你执行工具、把真实结果喂回给它,它再决定下一步。

这一步的核心思想:菜单与厨房分离 工具分两半:菜单tools 数组)发给模型,告诉它"你能干什么、参数长什么样";厨房executeTool)由你执行。模型只"提出请求",执行权永远在你手里——这既是安全(模型不能直接跑你的代码),也是职责分离(改菜单不影响厨房)。

3.1 升级 src/types.ts:新增 RawToolCall,role 加 "tool"

src/types.ts(升级)
升级前:src/types.ts(task 2)

3.2 新建 src/tools.ts:工具说明书 + executeTool

src/tools.ts
"parameters 内部"到底是谁的规则? 注意看注释里的标记:type/properties/required[JSON Schema](通用标准)定的;而它们外面的 type: "function"function.name[协议] 定的。初学者最容易混的就是这两层——记住:最外层是 OpenAI 兼容协议,parameters 里面是 JSON Schema 标准。至于 cityexpression 这些参数名,是你自己起的(但要和 executeTool 里一致)。
两个最容易踩的坑required: ["city"] 里的 "city" 必须和 properties 里的 city 拼写一致(这是你自己的约定,错了类型检查查不出来,模型会传不出参数);② schema 的 nameexecuteTool 的 case 必须一致——不一致时工具会静默失效,模型收到"未知工具"。

3.3 修改 src/llm.ts:返回值升级为 LLMResponse,支持传 tools

src/llm.ts(修改)
升级前:src/llm.ts(task 2)
为什么返回值要变成对象而不是纯文本? 因为从这一步起,模型的回复有两种可能:① 直接回答(content 有值);② 提出调用工具(tool_calls 有值,content 可能为 null)。一个返回值装不下两种信息,所以升级成 LLMResponse 对象。这也是"接口形状"设计的常见思路:一次通信可能返回多种结果时,用对象 + 可选字段承载

3.4 重写 src/agent.ts:主循环里处理工具调用

src/agent.ts(重写)
升级前:src/agent.ts(task 2)
为什么这样设计?assistant 带 tool_calls 的消息必须原样回传:这是协议硬性要求,缺了服务器会报错;② 结果用 role:"tool" + tool_call_id 回填:让模型知道"这条结果是哪次调用的回答",它才能正确解读;③ continue 而不是 break:工具调用后必须给模型下一轮"看结果→决定下一步"的机会;④ 为什么能拿到正确结果而不是瞎编:因为结果进了历史。LLM 无状态,你不喂给它,它就不知道。
常见坑 ① 忘把 assistant 的 tool_calls 消息 push 回历史 → 报错或工具链断裂;② 忘 JSON.parse arguments → 把字符串当对象用;③ tool_call_id 填错 → 模型无法关联结果,逻辑错乱;④ 工具名(schema 的 name)与 executeTool 的 case 不一致 → 返回"未知工具"。

3.5 运行验收

终端 · 运行命令
?

字段来源速查表(哪些名字不能改)

编程时对照这张表判断:一个名字到底能不能改、该不该保持一致。

字段 / 值能不能改谁规定的说明
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 里的参数名(cityexpression自己起你自己但必须和 executeToolargs.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_KEYMAX_STEPS[DONE]随便起你自己自己发明的约定,改时注意所有引用处
终极判断口诀 上网络的字段名 = 协议定,不能改;不上网络的 = 你定,但要对齐。类型检查(tsc)能帮你抓住"拼写不一致",但抓不住"该用协议名却写错协议名"——所以背熟上面这张表。
← Home