实现一个 Coding Agent(1):一次 LLM 调用

这是本系列的第一篇。我们会从最朴素的一次 HTTP 请求开始,一章一章地把一个真正能读写文件、执行命令、自己决定下一步干什么的编码助手(本系列叫它 pi)搭出来------不用任何 agent 框架,每一行代码都自己写、都讲清楚为什么。

写在前面:这个系列要干什么

市面上的 coding agent(Claude Code、Cursor、Cline......)看起来很神秘:它能听懂你的需求,自己读代码、改文件、跑测试、看报错、再修。

这个系列的目标,就是带你从零、用纯 TypeScript、不依赖任何 agent 框架,一行一行把这些零件造出来并拼成一个完整的 coding agent

我们会一边写,一边对照真实开源实现 pi 的做法,告诉你"玩具版"和"生产版"的差距在哪。

适合谁读:会一点 TypeScript/JavaScript,对大模型 API 好奇,想知道"agent 到底是怎么转起来的"的人。不需要你提前懂 agent,我们从零讲起。

怎么读:每篇都能独立跑通,代码全部在本仓库里(见每章 §写代码 一节)。建议边读边敲、边敲边跑。

好,热身结束,从最简单的一次请求开始。


第 1 章 一次 LLM 调用

本章我们只用一次普通的 HTTP fetch,发一段文字给一个大模型,把它的回复打印出来。

目标是把"模型对话"这件事从神秘感里拆出来------它其实就是一个 POST 请求,请求体是 JSON,响应体是 JSON,仅此而已。 剩下所有花活(流式、工具调用、agent 循环)都是绕着这个 JSON 在打转。

1.1 我们要做什么

终端里跑:

bash 复制代码
$ npx tsx hello.ts "用一句话介绍一下二分搜索"
二分搜索是一种在有序数组中通过反复将搜索范围对半折叠,从而以 O(log n) 时间找到目标值的算法。

不到 60 行 TypeScript,没有任何 SDK,只用 fetch。但写完这一章你会知道:

  • 大模型 API 长什么样(请求体、响应体)
  • messages[]rolecontent 这些字段分别表示什么
  • 为什么响应里要有个 choices[],且通常只用 choices[0]
  • system / user / assistant / tool 这四种 role 各自的含义
  • finish_reason 是干嘛的,为什么后面 agent 循环需要看它
  • tool_calls 字段长什么样,虽然这一章我们还不用它,但要混个脸熟

1.2 前置:找一个 OpenAI 兼容的 API

业界几乎所有"自托管 / 商用 / 开源"的大模型推理服务都提供 OpenAI 兼容的 HTTP 接口(叫 "OpenAI Chat Completions API"),具体说就是:

  • 同一个端点后缀 :在 base_url 后面拼 /chat/completions
  • 同一个请求体 schema:{ model, messages, ... }
  • 同一个响应体 schema:{ id, choices: [...], usage: ... }

⚠️ 注意:真正固定的只有末尾的 /chat/completions前面的版本前缀各家并不一样 ------OpenAI 是 /v1、阿里通义是 /compatible-mode/v1、火山方舟是 /api/v3,DeepSeek 甚至可以不带版本。 所以正确的心智模型是:把版本前缀算进 base_url ,代码只负责在末尾拼 /chat/completions(这也正是 OpenAI 官方 SDK 的做法)。这样一套代码就能切换所有厂商。

谁会兼容它(下面给的都是 base_url ,末尾再拼 /chat/completions 才是完整端点地址):

  • OpenAI 自己(https://api.openai.com/v1
  • DeepSeek(https://api.deepseek.com,也接受 https://api.deepseek.com/v1
  • Moonshot / Kimi(https://api.moonshot.cn/v1
  • 阿里通义(https://dashscope.aliyuncs.com/compatible-mode/v1
  • 火山方舟 Ark(https://ark.cn-beijing.volces.com/api/v3,注意是 /api/v3,而且 model 要填接入点 id,形如 ep-xxxxxxxx
  • 自部署的 vLLM / Ollama / LM Studio(端口随你)
  • 大多数云厂商提供的"OpenAI 兼容"端点

Anthropic(Claude)和 Google(Gemini)有自己的协议,走这条 URL。我们到第 9 章再处理它们。本章只用 OpenAI 兼容协议------一个写明白了,剩下两个是同一回事。

准备工作

  1. 注册一个 DeepSeek / Moonshot / 任意 OpenAI 兼容服务的账号,拿到 API key。
  2. 找到它的 base_url含版本前缀 ),比如 OpenAI 是 https://api.openai.com/v1,DeepSeek 用 https://api.deepseek.com/v1,火山方舟是 https://ark.cn-beijing.volces.com/api/v3
  3. 找到一个能用的模型名 :DeepSeek 是 deepseek-chat,OpenAI 是 gpt-4o-mini,火山方舟则是接入点 id(ep-xxxxxxxx),等等。
  4. 把它们丢进环境变量:
bash 复制代码
export PI_BASE_URL="https://api.deepseek.com/v1"
export PI_API_KEY="sk-xxxxxxxxxxxx"
export PI_MODEL="deepseek-chat"

如果你只有 Anthropic 或 Google 的 key,先去申请一个免费额度的兼容服务,否则这一章跑不通。

1.3 请求体(request body):messages 是什么

我们要 POST 给 https://api.deepseek.com/v1/chat/completions 的 JSON 大概长这样:

json 复制代码
{
  "model": "deepseek-chat",
  "messages": [
    { "role": "system", "content": "你是一个简明扼要的助手。" },
    { "role": "user",   "content": "用一句话介绍一下二分搜索" }
  ]
}

字段不多,但每个都要讲清楚。

model:选哪个模型

字符串,告诉服务端"用哪个模型来回答"。不同服务的取值是独立的(gpt-4o-mini 在 DeepSeek 上不存在)。

pi 用一个统一的模型注册表packages/ai/src/models.generated.ts,5000+ 行)来管理"哪个 provider/id 对应哪个 baseUrl、上下文窗口多大、单价多少"------这就是第 9 章要做的事情。

messages:一段对话历史

这是核心。它是一个数组,每一项叫做一条 message。

为什么是数组而不是单个字符串?因为这个 API 是无状态的:服务端不会记得"你上一轮问了什么、它上一轮答了什么"。

每次调用,你都要把整段对话历史完整传一遍。它根据这段历史预测下一句应该说什么。

这一点很重要,写 agent 时你会一直跟它打交道:所谓"上下文"就是这个数组;所谓"压缩上下文"就是把这个数组改短;所谓"会话持久化"就是把这个数组存到磁盘上。

一条 message 的结构
ts 复制代码
{
  role: "system" | "user" | "assistant" | "tool",
  content: string | Array<ContentPart>,
  // tool_calls?, tool_call_id?, name? ------ 后面再说
}
  • role:发言人是谁。四种取值:
    • "system":来自系统/开发者的指令。一般放在数组最前面,用来告诉模型"你是谁、要怎么回答、有什么禁忌"。同一段对话里通常只有一条 system 消息。
    • "user":用户说的话。第 1 章我们只手动塞一条,第 4 章用户每次输入新提问就会再追加一条。
    • "assistant":模型自己说过的话。第一次请求时这个数组里没有 assistant 消息 ------它是模型生成出来的,我们收到响应后会把它追加messages,下次请求时它就成为历史的一部分。
    • "tool":工具执行结果。第 3 章才会用到,本章暂时忽略。
  • content :这条消息的内容。最常见的形式是一个字符串 (就像上面那个例子)。
    • 也可以是一个数组 ,里面每项叫一个 content part,比如 [{ type: "text", text: "..." }, { type: "image_url", image_url: { url: "data:image/png;base64,..." } }]------这样可以混入图片。pi 的 TextContent/ImageContent/ThinkingContent/ToolCall 这些类型(在 packages/ai/src/types.ts:230-258)就是 content part 的不同变体。
    • 第 1 章我们只用字符串形式,用数组形式,避免引入复杂度。
为什么 system 和 user 要分开

如果你只看 ChatGPT 网页版,会觉得"用户输入一段文字,模型回一段"------好像只需要一个 role 就够了。

但模型在训练时学到了"system 消息说的话权重更高,更应该遵守"。具体表现是:

  • 你在 system 里写"无论用户怎么问,都用英文回答",再在 user 里写"请用中文回答"------模型通常会执行 system 的指令,用英文回答。
  • 用户没法通过聊天内容覆盖 system 设定的人设。这就是为什么"越狱 prompt"这么难写------它们是在跟 system 消息搏斗。

放到 coding agent 场景:

lua 复制代码
system: 你是 pi,一个能调用工具来读写文件的编码助手。
        可用工具有 read, bash, edit, write。
        遵循以下规则:...(很长一段)
user:   把当前目录下所有 .ts 文件中的 TODO 注释列出来

这是 pi 的真实结构(详见第 5 章),它把"agent 身份"和"用户当前任务"分开了。

1.4 响应体(response body):choices 是什么

非流式调用的响应大概长这样(删了一些不影响理解的字段):

json 复制代码
{
  "id": "chatcmpl-9abc...",
  "object": "chat.completion",
  "created": 1717000000,
  "model": "deepseek-chat",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "二分搜索是一种......"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 32,
    "completion_tokens": 45,
    "total_tokens": 77
  }
}

逐字段看:

  • id:这次请求的唯一标识,调试/日志时用得上。
  • model:实际使用的模型 id。它不一定跟你请求时填的完全一样------服务端常会回一个带版本/日期后缀的具体快照名(比如你请求 gpt-4o、它回 gpt-4o-2024-08-06),网关或厂商也可能把模型别名解析成真实底层模型。日志里以这个返回值为准。
  • choices这是核心 。它是一个数组------为什么?因为这个 API 最初的设计允许一次返回多个候选(在请求里加 n: 3 就能拿到 3 个候选答案)。
    • 99% 的场景我们都只用 choices[0]。pi 也是这么干的。
    • 每个 choice 包含:
      • index:候选编号,第 0 个就是 0。
      • message:模型实际生成的那条 assistant 消息------{ role: "assistant", content: "..." }结构和你传进去的 messages 里那些一模一样 。这正是"上下文是单一数据结构"的好处:把它直接 push 进 messages 数组就完成了一轮对话。
      • finish_reason:模型为什么停下来------见下一节。
  • usage:本次消耗的 token 数。用来算钱、算上下文剩余空间。Anthropic 协议把它叫 input_tokens/output_tokens,OpenAI 叫 prompt_tokens/completion_tokens,pi 在 packages/ai/src/types.ts:260-273 用一个统一的 Usage 类型把它们抹平。

finish_reason 都有哪些值

这个字段非常重要,agent 循环(第 4 章)就是靠它判断"该不该继续往下走"。常见取值:

含义 agent 该怎么做
"stop" 模型说完了,自然停止 把答案给用户看,等用户下一个输入
"length" 触发了 max_tokens 限制,被强行截断 报错或要求模型继续
"tool_calls" 模型决定调用工具(见第 3 章) 执行工具,拿到结果后再发一次请求
"content_filter" 触发了内容安全策略 通常显示一个提示让用户改 prompt

pi 把这些值归一化成 StopReasonpackages/ai/src/types.ts:275):"stop" | "length" | "toolUse" | "error" | "aborted"

1.5 写代码

📦 本章完整源码就在当前这个 git 仓库里:docs/booklet/code/ch01/hello.ts(GitHub:build-your-coding-agent)。想直接跑的话,clone 下来配好下面三个环境变量就行,不必照着手敲。

新建一个目录,初始化项目:

bash 复制代码
mkdir hello-llm && cd hello-llm
npm init -y
npm install --save-dev tsx typescript @types/node

把下面这段 60 行代码存成 hello.ts

ts 复制代码
// hello.ts
const baseUrl = process.env.PI_BASE_URL;
const apiKey  = process.env.PI_API_KEY;
const model   = process.env.PI_MODEL;

if (!baseUrl || !apiKey || !model) {
  console.error("请设置 PI_BASE_URL / PI_API_KEY / PI_MODEL 环境变量");
  process.exit(1);
}

// 1) 把命令行参数拼成一段提问
const userInput = process.argv.slice(2).join(" ").trim();
if (!userInput) {
  console.error("用法: tsx hello.ts <你的问题>");
  process.exit(1);
}

// 2) 准备 messages 数组
const messages = [
  { role: "system", content: "你是一个简明扼要的中文助手,回答控制在两句话以内。" },
  { role: "user",   content: userInput },
];

// 3) 发请求
//    baseUrl 已经带上了厂商的版本前缀(/v1、/api/v3...),这里只拼固定的 /chat/completions
const resp = await fetch(`${baseUrl}/chat/completions`, {
  method: "POST",
  headers: {
    "Content-Type":  "application/json",
    "Authorization": `Bearer ${apiKey}`,
  },
  body: JSON.stringify({
    model,
    messages,
    temperature: 0.2,
    // 注意:本章不流式,所以不带 stream: true
  }),
});

// 4) HTTP 层错误处理
if (!resp.ok) {
  const errBody = await resp.text();
  console.error(`HTTP ${resp.status}: ${errBody}`);
  process.exit(1);
}

// 5) 解析响应
const data = await resp.json();
const choice = data.choices[0];
const reply  = choice.message.content;

// 6) 打印
console.log(reply);
console.log(`\n--- (finish_reason: ${choice.finish_reason}, ` +
            `tokens: ${data.usage?.prompt_tokens} in / ${data.usage?.completion_tokens} out) ---`);

跑起来:

bash 复制代码
npx tsx hello.ts "用一句话介绍一下二分搜索"

输出:

逐段拆解(这是重点)

第 1 段:读环境变量
ts 复制代码
const baseUrl = process.env.PI_BASE_URL;
const apiKey  = process.env.PI_API_KEY;
const model   = process.env.PI_MODEL;

process.env 是 Node 内置的全局对象,对应你 shell 里 export PI_API_KEY=... 设置的环境变量。

为什么不直接把 API key 写死在代码里? 因为:

  1. 你不希望 key 被 commit 到 git。
  2. 同一份代码要在多个机器/CI 上跑,每台机器换 key 不该改代码。
  3. 第 4 章我们会换不同 provider,环境变量是切换最快的方式。

pi 自己用一个更结构化的方案------AuthStoragepackages/coding-agent/src/core/auth-storage.ts)把 key 存到 ~/.pi/auth.json,存的是明文 JSON ,靠把文件权限设成 0o600(仅本人可读写)来保护,不是加密------但这一章我们用最简陋的环境变量方案就够了。

第 2 段:拼 messages
ts 复制代码
const messages = [
  { role: "system", content: "你是一个简明扼要的中文助手,回答控制在两句话以内。" },
  { role: "user",   content: userInput },
];

我们刚刚在 1.3 节讲的所有内容都体现在这里:

  • system 消息在前,定义助手的"风格"("简明扼要 / 两句话")。
  • user 消息在后,承载本次请求的实际问题。
  • 没有 assistant 消息------因为还没轮到模型说话。
第 3 段:发请求
ts 复制代码
const resp = await fetch(`${baseUrl}/chat/completions`, {
  method: "POST",
  headers: {
    "Content-Type":  "application/json",
    "Authorization": `Bearer ${apiKey}`,
  },
  body: JSON.stringify({
    model,
    messages,
    temperature: 0.2,
  }),
});
  • fetch 是 Node 18+ 内置的 web 标准 API,浏览器里那个 fetch 跟它接口一致。
  • HTTP 方法是 POST,URL 末尾拼的是 /chat/completions------这才是 OpenAI 兼容协议真正固定的部分 ;版本前缀(/v1/api/v3 等)各家不同,已经包含在 baseUrl 里了,所以代码里不要再写死 /v1
  • Authorization: Bearer <key> 是 OpenAI 协议规定的鉴权头。Anthropic 用 x-api-key、Google 用 query param,这是它们的协议差异之一(第 9 章会展开)。
  • body 是 JSON 字符串。除了 modelmessages,我们加了一个:
    • temperature:采样温度,取值 02,越低答案越确定(同样输入给同样输出的概率越大),越高越发散。Coding agent 通常用 0.00.3,因为我们要的是"严格、可复现"的工具调用,不是诗意发挥。pi 默认也走 0.2 附近的策略。
  • 我们没有stream: true,所以这是非流式调用------服务端会生成完整答案后一次性返回。这意味着如果回答很长,你要等几秒钟才能看到任何字符。第 2 章会修掉这个体验。
第 4 段:HTTP 错误处理
ts 复制代码
if (!resp.ok) {
  const errBody = await resp.text();
  console.error(`HTTP ${resp.status}: ${errBody}`);
  process.exit(1);
}

resp.okfetch 提供的快捷属性,表示 status 在 200~299 之间。常见的失败 status:

  • 401:API key 不对或者过期。
  • 429:调用太频繁,被限流。pi 在 packages/ai/src/providers/openai-completions.ts 里专门处理了这个(带 maxRetries、指数退避),第 9 章会讲。
  • 400:请求体格式不对,看 errBody 里的 message。
  • 500:服务端炸了,重试。

第 1 章我们简单粗暴,错了就退出。

第 5 段:解析响应
ts 复制代码
const data = await resp.json();
const choice = data.choices[0];
const reply  = choice.message.content;
  • resp.json()fetch 标准 API,把 body 反序列化成 JS 对象。
  • data.choices[0] 取第一个候选------前面说过 99% 场景只用这个。
  • choice.message.content 是字符串形式的答案。

注意 choice.message 的类型和我们传进去的 messages 完全同构({ role: "assistant", content: "..." })。你可以这么写:

ts 复制代码
messages.push(choice.message);

然后用这个新数组发第二次请求,就实现了"多轮对话"。这是第 4 章 agent 循环的基础。

第 6 段:打印
ts 复制代码
console.log(reply);
console.log(`\n--- (finish_reason: ${choice.finish_reason}, ...) ---`);

把答案和元信息(finish_reason、token 数)一起印出来。养成在调试时把 finish_reason 也打出来的习惯,它对后续 agent 循环至关重要。

1.6 顺便看一眼 tool_calls(混个脸熟)

调用工具的请求体长什么样、响应里 tool_calls 字段长什么样,先在这里"瞟一眼"。

如果你的请求体里加上 tools 数组:

json 复制代码
{
  "model": "deepseek-chat",
  "messages": [
    { "role": "system", "content": "..." },
    { "role": "user", "content": "ls 一下当前目录" }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "bash",
        "description": "执行一条 bash 命令并返回 stdout/stderr",
        "parameters": {
          "type": "object",
          "properties": {
            "command": { "type": "string", "description": "要执行的命令" }
          },
          "required": ["command"]
        }
      }
    }
  ]
}

模型可能回这样的响应:

json 复制代码
{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_abc123",
            "type": "function",
            "function": {
              "name": "bash",
              "arguments": "{\"command\":\"ls -la\"}"
            }
          }
        ]
      },
      "finish_reason": "tool_calls"
    }
  ]
}

关键点:

  • content 变成了 null------模型这次不说话,而是要求"调工具"。
  • 多出一个 tool_calls 数组,每项有 id(这一次工具调用的唯一编号)、function.name(工具名)、function.argumentsJSON 字符串 ,要 JSON.parse!并不是一个真正的对象)。
  • finish_reason 变成 "tool_calls",告诉你"模型还没结束,等你执行工具"。

到时候我们要做的事情就是:执行 bash ls -la,把结果作为 role: "tool" 消息追加到 messages 里,再发一次请求。

1.7 本章产出

你现在拥有:

  • 一个能跑的最小 LLM 客户端(60 行 TS)。
  • messages / role / content / choices / finish_reason / usage 这些字段的清晰理解。
  • tool_calls 字段长什么样的初步印象。

没有的:

  • 流式输出(用户要等"完整答案出来"才能看到任何字符)
  • 工具调用
  • 任何 agent 行为

下一章我们解决第一个问题:让响应一个字一个字地流出来。我们要从一次性的 await fetch(...) 转去解析 Server-Sent Events,把它喂给一个 AsyncIterable

关注我的公众号《梦境引擎》,带你一起学习 AI!

相关推荐
nuIl1 小时前
实现一个 Coding Agent(2):让 LLM 流式响应
前端·agent·cursor
copyer_xyf1 小时前
Python 异常处理
前端·后端·python
sugar__salt1 小时前
从栈队列数据结构到JS原型面向对象全解
前端·javascript·数据结构
独特的螺狮粉2 小时前
篮球集训班器具管理系统 - 鸿蒙PC Electron框架完整技术实现指南
前端·javascript·华为·electron·前端框架·开源·鸿蒙
pusheng20252 小时前
IFSJ全英文专访:中国创新力量重塑先进气体感知技术,赋能全球关键基础设施安全
前端·网络·人工智能·物联网·安全
装不满的克莱因瓶2 小时前
学习 LCEL 表达式:降低 LLM 应用开发难度
人工智能·ai·langchain·agent·智能体·lcel·langgraph
李燚2 小时前
Eino 的 ReAct 循环是怎么跑起来的:图、节点、分支
golang·agent·react·ai-agent
AI_零食2 小时前
番茄钟鸿蒙PC Electron框架完成:状态机、定时器管理与专注力工具设计
前端·javascript·华为·electron·开源·鸿蒙·鸿蒙系统
提子拌饭1332 小时前
逛三园游戏——基于鸿蒙PC Electron框架实现
前端·javascript·游戏·华为·electron·鸿蒙