从零开始,到第一次调用 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'。这是因为这个类型专门表示模型返回的消息,而不是任意消息。
第二,content 是 string | 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 有一个容易记错的地方。
当服务器返回 401、429 或 500 时,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”之类的二次错误,真正的 401 或 429 反而被掩盖。
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() 的链路就是:
七、把入口真正串起来
有了配置读取、有了类型、有了 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,因为它只在编译阶段用于类型检查,并不是运行时需要调用的对象。getApiKey 和 LlmClient 则是运行时真实存在的值,所以使用普通 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
这一次,程序会真正经历:
这条链路第一次真正跑通。
模型给出了回复。
看起来只是终端里多了一行文字,但对这个项目而言,这意味着一些事情终于从“设计”变成了“事实”:
.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,由程序找到对应工具、执行工具,再把结果交还给模型。
于是,下一阶段会逐步完成:
- 定义 Tool Definition:让模型知道有哪些工具,以及每个工具需要什么参数。
- 定义 Tool Call:让程序能够理解模型想调用什么工具。
- 注册第一个真实工具,例如
read_file。 - 执行工具并处理结果。
- 把工具结果写回对话历史。
- 实现最小 ReAct Loop:
Reasoning → Action → Observation
↑ ↓
└──────继续───────┘
届时,nipher-harness 才会从一个“能够调用 LLM 的程序”,开始接近一个真正的 Agent。
现在回头看,第一阶段并不宏大。
它只是新建了一个项目,写了几种类型,组装了一个 HTTP Client,然后等待模型返回第一句话。
可所有稍微复杂的系统,最初都要先完成这样一件近乎朴素的事情:
让第一条链路,真实地活起来。
当前阶段:第一阶段已完成
- 使用 Bun + TypeScript 创建项目
- 配置
.env与 API Key 读取 - 定义最小消息类型
- 实现 DeepSeek LLM Client
- 通过 TypeScript 严格类型检查
- 成功完成第一次真实 DeepSeek 调用
下一篇:工具调用与 ReAct 循环:让 nipher-harness 开始行动