从零开始,到第一次调用 LLM

记录 nipher-harness 的第一阶段开发过程:从项目立项与技术选型开始,使用 Bun 与 TypeScript 搭建基础结构,完成 DeepSeek API 接入,并让程序真正获得第一次模型回复。

这是一个持续迭代的教程。

它不会在第一篇文章里假装把一个完整的 Agent 平台讲完,也不会把尚未实现的能力,提前包装成已经完成的成果。项目会向前走,教程就跟着项目一起向前走;代码在什么时候完成,文章就记录到什么时候。

这一点需要先说明。

nipher-harness 并不是一个已经封装好的成品,而是我正在从零开始搭建的项目。它会经历选择、试错、修改,以及许多并不体面的中间状态。教程要记录的,不只是最后能够运行的代码,也包括代码为什么这样组织,某个问题是怎样被发现的,以及一个看起来简单的功能,为什么需要经过几层思考才能真正落地。

一、我想做的,究竟是什么

nipher-harness 的最终目标,是做成一个带客户端界面的 Agent 平台

它想要成为的,不是一个只有命令行入口、能够调用工具的 Agent,也不是一个把 Agent 核心机制塞进 CLI 里的小型脚本。它更接近 DeepSeek Harness、Proma 这类产品:拥有客户端界面,能够承载对话、任务执行、工具调用、上下文管理,以及之后逐步加入的更多 Agent 能力。

当然,最终目标很远,而项目必须从很近的地方开始。

一个带客户端界面的 Agent 平台,底层仍然要先回答一些最基础的问题:

  • 程序如何把用户消息交给 LLM?
  • LLM 返回的内容,程序如何解析?
  • 当模型返回结构化的 Tool Call 时,程序如何理解它的意图?
  • 如何让工具真正执行,而不是停留在一段 JSON 上?
  • 工具执行结束之后,结果如何回到模型的上下文里?

但这些问题不会在第一阶段一次解决。

第一阶段只有一个目标:

从零搭建项目,并让它成功调用一次 LLM。

这是一个很小的目标,但它并不轻。

因为只有当配置、类型、HTTP 请求、响应解析,以及程序入口真正串起来之后,后面的 Tool Calling、ReAct Loop、Context 管理和客户端界面,才有可以依靠的地基。

二、为什么选择 TypeScript + Bun

最开始,我曾经考虑过使用 Java 来实现 Agent 项目。Java 当然可以实现 Agent,ReAct、工具调用、上下文管理、安全边界,这些机制并不属于某一种语言。

但是,语言不仅仅决定代码怎样写,也决定我们更容易进入哪一类项目、使用哪一类生态,以及之后阅读什么样的源码。

nipher-harness 的目标,是参与类似 Proma、DeepSeek Harness 这样的项目开发。它们的代码都大量使用 TypeScript,运行时和工程工具链也与 JavaScript 生态紧密相关。因此,这一次我把项目的主实现语言确定为 TypeScript,并使用 Bun 作为运行时与工具链。

这不是因为 TypeScript 比 Java“高级”,也不是因为 Java 不能做 Agent,而是因为:

项目想走向哪里,起点的技术栈就应该尽量靠近哪里。

Bun 则负责提供一个足够轻量的开发起点。它自带运行时、包管理、TypeScript 执行能力,以及 .env 的自动加载能力。对于当前这个仍处在地基阶段的项目来说,先使用简单直接的工具,比一开始就堆叠复杂的工程配置更合适。

这一阶段并没有引入前端框架,也没有引入 Electron。不是因为客户端界面不重要,而是因为客户端界面属于最终目标,不是第一阶段最应该解决的问题。

先让底层链路可靠地活起来,再给它安排一个合适的外表。

三、创建一个干净的项目

项目名确定为:

nipher-harness

这是一个使用 Bun 初始化的 TypeScript 项目。最初的目录并不复杂:

nipher-harness/
├── .env                  # 本地环境变量,不提交到 Git
├── .gitignore
├── package.json
├── bun.lock
├── tsconfig.json
└── src/
    └── index.ts          # 程序入口

初始化阶段的 package.json 保持了尽可能少的内容:

{
  "name": "nipher-harness",
  "module": "src/index.ts",
  "type": "module",
  "private": true,
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "typescript": "^5"
  }
}

这里的 private: true 表示当前包不会被当作公共 npm 包发布。此时我们要做的是自己的项目,而不是准备向 npm 发布一个库。

module 指向 src/index.ts,这意味着项目有了明确的入口。后面不论是直接运行,还是继续扩展目录结构,都不必把代码全部塞在项目根目录里。

第一版入口只是 Bun 初始化时生成的问候语。它没有任何 Agent 能力,却也并非没有意义——项目首先需要证明,运行时、目录和基础配置能够正常工作。

四、先把 API Key 放在代码之外

调用 LLM 的第一件事不是写 fetch,而是先想清楚 API Key 应该放在哪里。

API Key 是凭证,不是业务代码。

如果把它直接写在 src/index.ts 里,代码也许可以运行,但只要这个文件被提交到 GitHub,Key 就可能随之暴露。即使之后把这行代码删掉,提交历史里也仍然可能留下它。

因此,项目使用根目录下的 .env 文件保存本地配置:

DEEPSEEK_API_KEY=你的DeepSeek_API_Key

.env 不会被提交到 Git,因为 .gitignore 中明确排除了它:

.env
.env.development.local
.env.test.local
.env.production.local
.env.local

Bun 会自动加载项目根目录下的 .env 文件。代码不需要安装 dotenv,但仍然需要通过 process.env 读取环境变量。

这两个概念不能混在一起:

.env                  → 配置值的存放位置
process.env            → 程序读取配置的入口

项目把读取 API Key 的动作单独放在 src/config.ts

export const getApiKey = (): string => {
  const apiKey = process.env.DEEPSEEK_API_KEY;

  if (!apiKey) {
    throw new Error('请设置 DEEPSEEK_API_KEY');
  }

  return apiKey;
};

这里的 !apiKey 同时拦截了几种情况:环境变量不存在、值为空,以及值为其他“没有实际内容”的情况。

这个函数虽然很短,但它承担了一个清晰的责任:

程序需要 API Key 时,统一从这里读取;如果没有,就尽早失败。

而不是等到请求已经发出去,才在 HTTP 层得到一个难以理解的 401

五、先定义消息,而不是先写请求

Java 开发中,我们习惯先定义实体类或 DTO。TypeScript 里的 type,也可以承担类似的作用,只是表达方式更轻量。

LLM 对话最基本的消息,需要两个字段:

  • role:这条消息是谁发出的
  • content:消息的文本内容

项目在 src/types.ts 中先定义了这些类型:

export type Role = 'user' | 'assistant' | 'system';

export type Message = {
  role: Role;
  content: string;
};

Role 不是普通的 string,而是一个联合类型:

'user' | 'assistant' | 'system'

这意味着下面的写法会被允许:

const message: Message = {
  role: 'user',
  content: '你好。',
};

而下面的写法会被 TypeScript 拒绝:

const message: Message = {
  role: 'administrator',
  content: '你好。',
};

因为 LLM 对话协议并不接受任意角色。把合法值限制在类型中,错误就会在代码运行以前暴露出来,而不是等请求到达远程 API 后才发现。

模型返回的消息和用户发送的消息并不完全相同。它的角色固定为 assistant,而 content 又可能为空,因此第一阶段先定义:

export type AssistantMessage = {
  role: 'assistant';
  content: string | null;
};

这里有两个值得注意的地方。

第一,role 被收窄为 'assistant'。这是因为这个类型专门表示模型返回的消息,而不是任意消息。

第二,contentstring | null。即使当前测试调用期望获得一段文本,协议层也不应该把“每一次模型响应都必定有文本”当成无需说明的前提。入口代码会在打印前显式处理 null

在当前代码中,ToolCall 的协议类型也已经有了一个初步定义。它描述工具调用的 id、类型、工具名称和参数字符串。但这还不等于工具调用能力已经完成:类型只是协议层的容器,后面仍然需要补上 Tool Definition、工具注册、参数解析、实际执行,以及结果回灌。

第一阶段先把消息协议的最小部分和模型接入链路站稳。类型是地图,不是已经走完的路。

六、实现第一个 LLM Client

消息类型确定之后,才开始实现 src/llm.ts

它的职责很单纯:

接收项目内部的 Message[]
→ 组装 HTTP 请求
→ 调用 DeepSeek chat completions API
→ 解析响应
→ 返回 AssistantMessage

项目使用的是 DeepSeek 的 OpenAI-compatible 接口:

POST https://api.deepseek.com/v1/chat/completions

LlmClient 保存三个必要配置:

export class LlmClient {
  private apiKey: string;
  private model: string;
  private baseUrl: string;

  constructor(
    apiKey: string,
    model: string,
    baseUrl: string,
  ) {
    this.apiKey = apiKey;
    this.model = model;
    this.baseUrl = baseUrl;
  }
}

这里暂时没有把配置系统做得复杂。先让 Client 明确拥有调用模型所需的三样东西:凭证、模型名和服务地址。

6.1 发送 POST 请求

chat() 方法首先把请求发送出去:

async chat(messages: Message[]): Promise<AssistantMessage> {
  const response = await fetch(
    `${this.baseUrl}/v1/chat/completions`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: this.model,
        messages,
      }),
    },
  );

这里第一次真正接触到 TypeScript/Bun 中的 HTTP 调用,有几个点不能只记写法。

fetch() 是异步操作,因此返回的是 Promise<Response>。使用 await 之后,才得到 Response 对象。

method: 'POST' 表示向接口提交请求。

Authorization 使用的是:

Bearer API_KEY

这是 API 约定的认证格式。Content-Type: application/json 则告诉服务器,请求体是一段 JSON。

请求体不能直接把 JavaScript 对象塞进去,因此需要:

JSON.stringify({
  model: this.model,
  messages,
})

它负责把内存中的对象序列化为 HTTP 可以传输的 JSON 字符串。

6.2 不要把 HTTP 错误当成网络异常

fetch 有一个容易记错的地方。

当服务器返回 401429500 时,fetch 通常仍然会得到一个 Response。它不会因为 HTTP 状态码不是 2xx,就自动抛出异常。

因此必须主动检查:

if (!response.ok) {
  throw new Error(`LLM 请求失败: ${response.status}`);
}

状态关系可以写成:

HTTP 200~299
→ 得到 Response
→ response.ok === true

HTTP 401 / 429 / 500
→ 得到 Response
→ response.ok === false
→ 程序主动 throw

DNS 失败 / 断网 / 连接无法建立
→ fetch 本身可能直接 throw
→ 通常拿不到 Response

这其实是两层错误:

  • 网络层错误:请求没有成功建立,fetch 直接失败
  • HTTP 层错误:服务器已经回应,但状态码表示请求没有成功

如果不检查 response.ok,程序可能把错误响应继续当作正常响应解析,最后得到一个“没有 choices”之类的二次错误,真正的 401429 反而被掩盖。

6.3 解析响应 JSON

HTTP 响应对象仍然只是一个响应信封,不是我们最终需要的模型消息:

const data = await response.json();

response.json() 也是异步操作,所以这里仍然需要 await

DeepSeek 正常返回的数据大致是:

{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "你好,我是模型。"
      }
    }
  ]
}

因此,模型消息位于:

response JSON
└── choices
    └── 第 0 个元素
        └── message

项目在 llm.ts 中描述 API 响应的外层结构:

type ChatCompletionResponse = {
  choices: Array<{
    message: AssistantMessage;
  }>;
};

然后使用类型断言:

const data = await response.json() as ChatCompletionResponse;

这里的 as ChatCompletionResponse 只是在告诉 TypeScript:

我认为这段 JSON 遵循这个结构,请按照这个类型检查后续代码。

它不会真正转换 JSON,也不会在运行时验证远程数据。如果服务器返回了完全不符合预期的结构,类型断言本身不会替我们拦截。所以仍然要做最基本的存在性检查:

const message = data.choices[0]?.message;

if (!message) {
  throw new Error('LLM 响应中没有 message');
}

return message;

这里的 ?. 是 optional chaining。

如果 choices[0] 存在,就读取它的 message;如果数组为空,就得到 undefined,而不是立刻因为空值访问属性而崩溃。随后通过 if (!message) 主动抛出更清晰的异常。

最终,chat() 的链路就是:

flowchart A["Message[ ]"] --> B[组装 JSON 请求体] B --> C[fetch POST] C --> D{response.ok?} D -- 否 --> E[抛出 HTTP 错误] D -- 是 --> F[await response.json] F --> G["读取 choices[0].message"] G --> H{message 存在?} H -- 否 --> I[抛出响应结构错误] H -- 是 --> J[返回 AssistantMessage]

七、把入口真正串起来

有了配置读取、有了类型、有了 LLM Client,还需要在 src/index.ts 中把它们组合起来。

入口文件完成了下面这条链路:

getApiKey()
→ new LlmClient(...)
→ 创建 Message[]
→ await client.chat(messages)
→ 处理 content
→ 打印模型回复

核心代码如下:

import { getApiKey } from './config';
import { LlmClient } from './llm';
import type { Message } from './types';

async function main(): Promise<void> {
  const apiKey = getApiKey();

  const client = new LlmClient(
    apiKey,
    'deepseek-chat',
    'https://api.deepseek.com',
  );

  const messages: Message[] = [
    {
      role: 'system',
      content: '你是一个专业且说话有“人味”的助手',
    },
    {
      role: 'user',
      content: '请用一句话解释什么是 TypeScript。',
    },
  ];

  const reply = await client.chat(messages);

  if (reply.content === null) {
    console.log('模型没有返回文本内容');
    return;
  }

  console.log(reply.content);
}

main().catch((error) => {
  console.error('程序运行失败', error);
  process.exitCode = 1;
});

这里没有直接在文件顶层写一大段执行逻辑,而是使用 main() 统一组织异步流程,再通过 .catch() 处理未捕获异常。

Message 使用 import type,因为它只在编译阶段用于类型检查,并不是运行时需要调用的对象。getApiKeyLlmClient 则是运行时真实存在的值,所以使用普通 import

模型返回的 content 类型是 string | null。因此入口必须明确处理 null

if (reply.content === null) {
  console.log('模型没有返回文本内容');
  return;
}

完成这个判断之后,TypeScript 能够把后面的 reply.content 收窄为 string

八、先做静态检查,再做真实调用

代码写完后,第一步不是马上请求模型,而是先做静态类型检查:

bunx tsc --noEmit

这个命令不发送网络请求,也不消耗 API token。它只检查:

  • 跨文件导入是否正确
  • 类型是否匹配
  • async/await 是否符合声明
  • 严格模式下是否存在潜在错误
  • 数组索引和可空值是否被正确处理

类型检查通过,只能证明代码在类型层面自洽。

它不能证明 API Key 正确,不能证明远程服务可用,也不能证明接口响应一定符合预期。

所以第二步才是启动程序:

bun run src/index.ts

这一次,程序会真正经历:

sequenceDiagram participant E as .env participant P as nipher-harness participant D as DeepSeek API E->>P: Bun 自动加载 DEEPSEEK_API_KEY P->>P: getApiKey() P->>P: 创建 LlmClient P->>D: POST /v1/chat/completions D-->>P: 返回 choices[0].message P-->>P: 解析为 AssistantMessage P->>P: 输出模型回复

这条链路第一次真正跑通。

模型给出了回复。

看起来只是终端里多了一行文字,但对这个项目而言,这意味着一些事情终于从“设计”变成了“事实”:

  • .env 能被 Bun 正确加载
  • process.env.DEEPSEEK_API_KEY 能取到有效凭证
  • LlmClient 能够构造正确的请求
  • DeepSeek API 能够正常返回
  • 程序能够解析响应并取出模型消息
  • nipher-harness 已经拥有了自己的 LLM 接入层

这不是 Agent,还只是 Agent 的模型接入层。

但没有这一层,Agent 也无从谈起。

九、到这里,我们完成了什么

第一阶段的成果,可以压缩成一句话:

nipher-harness 已经能够读取配置,构造消息,通过 OpenAI-compatible API 调用 DeepSeek,并将模型回复交还给程序。

当前的目录大致如下:

nipher-harness/
├── .env
├── .gitignore
├── package.json
├── bun.lock
├── tsconfig.json
└── src/
    ├── config.ts    # 读取并校验 API Key
    ├── index.ts     # 程序入口,组装调用链
    ├── llm.ts       # LLM Client,请求与响应解析
    └── types.ts     # Message、AssistantMessage、ToolCall 等核心类型

不过,现在的程序距离最终目标仍然很远。

它还不能读取文件,不能执行工具,也不能根据工具结果继续思考。它只能把一组消息发送给模型,再把模型的回复打印出来。

换句话说,现在它还是:

用户输入 → LLM → 文本回复

而真正的 Agent 需要走向:

用户输入

LLM 判断下一步行动

返回 Tool Call

程序执行工具

工具结果回灌给 LLM

LLM 继续思考

直到返回最终文本

十、下一阶段:让它开始行动

第二阶段的主题已经确定:

搭建 Agent 的第一件事,就是完成工具调用与 ReAct 循环。

下一篇文章不会一开始就铺开所有 Agent 理论,而是从一个具体问题开始:

如果用户要求 Agent“读取当前项目的 README,并用三句话总结”,模型本身能够直接读取本地文件吗?

答案当然是否定的。

LLM 可以提出建议,却不能凭空触碰本地文件。它需要返回一个结构化的 Tool Call,由程序找到对应工具、执行工具,再把结果交还给模型。

于是,下一阶段会逐步完成:

  1. 定义 Tool Definition:让模型知道有哪些工具,以及每个工具需要什么参数。
  2. 定义 Tool Call:让程序能够理解模型想调用什么工具。
  3. 注册第一个真实工具,例如 read_file
  4. 执行工具并处理结果。
  5. 把工具结果写回对话历史。
  6. 实现最小 ReAct Loop:
Reasoning → Action → Observation
      ↑                 ↓
      └──────继续───────┘

届时,nipher-harness 才会从一个“能够调用 LLM 的程序”,开始接近一个真正的 Agent。

现在回头看,第一阶段并不宏大。

它只是新建了一个项目,写了几种类型,组装了一个 HTTP Client,然后等待模型返回第一句话。

可所有稍微复杂的系统,最初都要先完成这样一件近乎朴素的事情:

让第一条链路,真实地活起来。


当前阶段:第一阶段已完成

  • 使用 Bun + TypeScript 创建项目
  • 配置 .env 与 API Key 读取
  • 定义最小消息类型
  • 实现 DeepSeek LLM Client
  • 通过 TypeScript 严格类型检查
  • 成功完成第一次真实 DeepSeek 调用

下一篇:工具调用与 ReAct 循环:让 nipher-harness 开始行动

输入关键词搜索全部文章 · 支持快捷键 / 唤起