跳到主内容
创建时间:最后更新:作者:白川

搭建并运行你的第一个智能体

智能体看起来像一个"自己会干活"的程序,实际由三段确定的动作组成:把任务交给模型、把模型给出的工具调用变成真实操作、再把执行结果送回模型。缺任何一段,它都只是一个普通的接口调用。

本教程使用 Pi 的轻量 Agent Core 作为具体实现:pi-ai 负责连接模型,pi-agent-core 负责保存消息、调用模型和调度工具。这里没有安装完整编码 CLI,也不会自动获得读取文件或执行命令的能力。

示例模型选 DeepSeek 的 deepseek-flash。它的接口兼容 OpenAI 的 Chat Completions,Key 申请门槛低,适合用来验证"配置是否正确"这类最基础的问题。

这两个包分工明确,值得先看清楚,因为后面所有代码都建立在这个划分上:

包提供的导出承担什么
@earendil-works/pi-ai
createModels、deepseekProvider
模型目录、Provider 注册、一次请求的发起与流式结果
@earendil-works/pi-agent-core
Agent、AgentTool
消息状态、轮次循环、工具调度、取消与事件

pi-agent-core 自己依赖 pi-ai,所以安装前者会自动带上后者。你写的业务代码里不会出现任何 HTTP 请求细节——那是 pi-ai 的事。

创建项目并安装依赖

需要 Node.js 22.19.0 或更高版本。在你准备放练习代码的位置执行:

bash
mkdir agent-lab
cd agent-lab
npm init -y
npm pkg set type=module
npm install --save-exact @earendil-works/pi-agent-core@1.5.0 @earendil-works/pi-ai@1.5.0
npm install --save-dev --save-exact tsx@4.23.15 typescript@7.0.2 @types/node@26.6.6
mkdir src

type=module 让 Node 使用 ES Module。tsx 运行 TypeScript 文件;类型检查由 TypeScript 单独完成,执行成功不能代替类型检查。

--save-exact 不是多余的谨慎。Pi 自己的仓库规则是“直接外部依赖锁定精确版本,内部 workspace 包保持版本区间”,你的练习项目跟随同样的做法,能避开一类“我这儿能跑、那儿跑不了”的问题。

新建 .gitignore,至少写入:

text
node_modules/
.env

新建 .env,把占位内容换成你自己的 DeepSeek API Key:

dotenv
DEEPSEEK_API_KEY=replace-with-your-key
MODEL_ID=deepseek-flash

这里只选择一种连接方式,减少第一次运行的变量。三点需要先说清楚:

  • Key 来自 DeepSeek 开放平台,与聊天客户端的订阅不是一回事,订阅不会自动变成 API 余额;调用按 token 计费。
  • MODEL_ID 填服务真正接受的名字。DeepSeek 目前的常用模型是 deepseek-flash(响应快,适合日常对话)和 deepseek-v4-pro(能力更强,适合复杂任务);本篇用 deepseek-flash,换成 deepseek-v4-pro 只需要改这一行。
  • 凭据变量名由模型层统一映射,DeepSeek 对应 DEEPSEEK_API_KEY,映射表见模型层的环境变量表。

内置目录里的模型 ID 是编译进包里的,可能和你账号可用的模型不一致。deepseek-flash 和 deepseek-v4-pro 如果不在目录里,下面的 runtime.ts 会把当前目录里的全部 id 打进报错信息:照着改 .env 换成一个目录里真实存在的 id,或者用文末的自建 Provider 写法——那种方式下模型 ID 由服务决定,目录不再参与校验。

把模型与运行时连接起来

创建 src/runtime.ts:

typescript
import { Agent, type AgentTool } from "@earendil-works/pi-agent-core"
import { type AssistantMessage, createModels } from "@earendil-works/pi-ai"
import { deepseekProvider } from "@earendil-works/pi-ai/providers/deepseek"

export function createLabAgent(
  systemPrompt = "你是一个中文编程助手。回答简洁,不确定时明确说明。",
  tools: AgentTool<any>[] = [],
) {
  const apiKey = process.env.DEEPSEEK_API_KEY
  if (!apiKey) {
    throw new Error("请设置 DEEPSEEK_API_KEY")
  }
  const models = createModels()
  models.setProvider(deepseekProvider())
  const modelId = process.env.MODEL_ID || "deepseek-flash"
  const model = models.getModel("deepseek", modelId)
  if (!model) {
    const available = models.getModels("deepseek").map((item) => item.id).join(", ")
    throw new Error(`模型目录中不存在:${modelId}。当前目录:${available}`)
  }
  return new Agent({
    initialState: { systemPrompt, model, tools },
    streamFn: models.streamSimple.bind(models),
    getApiKey: () => apiKey,
  })
}

export function lastReply(agent: Agent): AssistantMessage {
  const message = agent.state.messages.findLast((item) => item.role === "assistant")
  if (message?.role !== "assistant") {
    throw new Error("没有助手结果")
  }
  if (message.stopReason === "error" || message.stopReason === "aborted") {
    throw new Error(message.errorMessage || message.stopReason)
  }
  return message
}

export function printReply(agent: Agent) {
  for (const block of lastReply(agent).content) {
    if (block.type === "text") {
      console.log(block.text)
    }
  }
}

这三段代码对应的是官方示例里的同一套接法(MCP + Codemode 示例),可以对照着看。

createLabAgent() 做三件事:注册 Provider,从它的目录取得模型,把模型调用函数交给运行时。streamFn 是两层之间的连接口,运行时通过它访问模型——它就是 pi-ai 的 streamSimple 方法。

systemPrompt 是对助手的长期要求,tools 是允许调用的工具列表。本篇不传工具,默认就是空列表。getApiKey 返回凭据给请求层,不要把 Key 拼进提示词或打印出来。

关于 getApiKey 有个容易忽略的细节:AgentOptions.getApiKey 的类型是 (provider: string) => Promise<string | undefined> | string | undefined,也就是它按 provider 名返回凭据。这里只接了 DeepSeek 一个 provider,所以忽略参数直接返回同一个 Key 是可行的;接多个 provider 时要按传入的 provider 名分别给凭据。

printReply() 先检查结果状态,再输出文本。某些模型失败会记录为助手的 error 消息,而不只是一个向外抛出的异常,因此不能仅凭 await prompt() 返回就宣布任务成功。

stopReason 有七个取值:pending、stop、length、toolUse、error、aborted、deferred。printReply() 只拦 error 和 aborted 两个——length 表示响应撞到了输出上限,工具调用可能因此没有执行,这一条留到接入第一个工具时再讲。

提交第一条任务

创建 src/main.ts:

typescript
import { createLabAgent, printReply } from "./runtime.js"

const agent = createLabAgent()
const timer = setTimeout(() => agent.abort(), 30_000)
try {
  await agent.prompt("用两句话解释 JavaScript 的函数是什么。")
  printReply(agent)
} finally {
  clearTimeout(timer)
  agent.abort()
  await agent.waitForIdle()
}

执行:

bash
node --env-file=.env --import tsx src/main.ts

预期是终端出现中文解释,随后程序退出。回答措辞不固定,不需要逐字匹配某一份样例。30 秒计时器是本例设置的上限;超时会请求取消,不表示模型一定能在此时间内完成。

导入路径写 ./runtime.js 是为了匹配 NodeNext 的模块约定;源文件仍然是 runtime.ts,tsx 会解析这类导入。不要额外创建一份 runtime.js。

运行时替你完成了什么

text
任务文本 → Agent 保存用户消息 → 模型请求 → 助手消息 → 打印结果

你没有自己管理请求后的消息回填,也没有写循环。运行时已经提供这些机制。当前没有工具,通常只需一次模型回复;接入工具后,一次 prompt() 可以触发多次模型请求。

finally 用来清除计时器、发出取消信号并等待运行结束。这里使用的 Agent Core 没有会话 SDK 的 dispose(),不要混用两套 API。

内置 Provider 不存在时的备选写法

模型层的内置 Provider 会随版本增减。如果运行时报 Cannot find module '@earendil-works/pi-ai/providers/deepseek',说明当前版本没有这个入口名。DeepSeek 提供 OpenAI 兼容的 Chat Completions 接口,可以自己拼一个 Provider 顶上:

typescript
import { createModels, createProvider, envApiKeyAuth, type Model } from "@earendil-works/pi-ai"
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"

const baseUrl = process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com/v1"
const model: Model<"openai-completions"> = {
  id: process.env.MODEL_ID || "deepseek-flash",
  name: "DeepSeek",
  provider: "deepseek",
  api: "openai-completions",
  baseUrl,
  reasoning: false,
  input: ["text"],
  // 下面两个数值按 DeepSeek 官方文档填写,这里只是占位。
  contextWindow: 65536,
  maxTokens: 4096,
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}

const models = createModels()
models.setProvider(
  createProvider({
    id: "deepseek",
    auth: { apiKey: envApiKeyAuth("DeepSeek", ["DEEPSEEK_API_KEY"]) },
    models: [model],
    api: openAICompletionsApi(),
  }),
)

自建 Provider 要自己交代清四件事:请求地址、模型 ID、协议和凭据变量名。cost 填零只是占位,不代表服务免费;contextWindow 与 maxTokens 填得比服务实际能力大,只会让客户端记录和真实能力对不上。

DeepSeek 的地址在官方文档里给过两种写法,https://api.deepseek.com 与 https://api.deepseek.com/v1,两者指向同一个服务,不要在客户端和网关各追加一次 /v1。兼容接口对参数的支持也有差异:某个字段被服务拒绝时,用模型层的 compat 字段按服务说明逐项覆盖,不要一次全关。

不写死导入路径也可以:内置 Provider 集合 builtinProviders() 能一次取出全部内置 Provider,再按 id === "deepseek" 找到需要的那一个。

怎么确认环境准备好了

检查三个结果:缺少 Key 时程序在发请求前报错;模型可用时输出文本;请求失败时没有打印伪造的成功信息。认证失败先查凭据和服务账号,模型不存在先查 ID 与模型目录,超时则分别排查网络和服务响应。

这些检查点各自能排除什么,值得分开看:

检查结果排除了什么还剩什么可能
启动即报“请设置 DEEPSEEK_API_KEY”
凭据缺失、.env 未加载
—
报“模型目录中不存在”
MODEL_ID 拼写、Provider 未注册
账号权限
正常输出文本
整条链路:凭据、模型、网络、协议
工具能力(尚未接入)

第三行能同时排除四类问题,所以它是这一步最有价值的信号。但它只证明文本路径可用——工具调用、文件读取是另一条路径,需要单独验证。

你现在能做什么

  • 有一个能启动、能回答中文问题的最小智能体:换模型只改 .env 里的 MODEL_ID,从 deepseek-flash 切到 deepseek-v4-pro 也是改这一行,其余代码不用动。
  • 有一条可判读的失败链路:凭据缺失、模型 ID 不存在、认证失败、超时各有各的报错,不会被一句“连接失败”盖过去。
  • 有一个可以继续扩展的骨架:runtime.ts 里的 createLabAgent(systemPrompt, tools) 已经为接入工具留好位置,流式调用、凭据解析、取消与错误处理都在原位。

参考资料