Skip to content

Instantly share code, notes, and snippets.

@ziyoung
Forked from dabit3/pi_tutorial.md
Last active July 29, 2026 10:37
Show Gist options
  • Select an option

  • Save ziyoung/8480c0d40f4b69b2a197a0664c7d0774 to your computer and use it in GitHub Desktop.

Select an option

Save ziyoung/8480c0d40f4b69b2a197a0664c7d0774 to your computer and use it in GitHub Desktop.
How to Build a Custom Agent Framework with PI: The Agent Stack Powering OpenClaw

Pi 教程(基于 DeepSeek)

Pi 是一套用 TypeScript 构建 AI Agent 的工具包。各包分层叠用:

  • pi-ai:跨厂商 LLM 通信(Models 集合、流式、工具定义、用量与费用)
  • pi-agent-core:在 pi-ai 之上加 Agent 循环(调模型 → 执行工具 → 回填 → 重复)
  • pi-coding-agent:完整编码 Agent 运行时(内置文件工具、JSONL 会话、压缩、扩展)
  • pi-tui:终端 UI(差分渲染、Markdown、多行编辑器、自动补全)

本教程按层递进。理解如何组合这些层之后,你可以按自己的产品边界组装 Agent,而不被某一层抽象锁死。

使用的包 scope 为 @earendil-works/*(与上游 @mariozechner/* API 同源演进)。0.82 起 pi-aiModels + Provider 工厂为主路径;旧的全局 getModel / streamSimple 仅在 @earendil-works/pi-ai/compat 过渡,正文不再使用。

技术栈

┌─────────────────────────────────────────┐
│  你的应用                               │
│  (CLI、Slack bot、OpenClaw 等)          │
├────────────────────┬────────────────────┤
│  pi-coding-agent   │  pi-tui            │
│  会话、工具、扩展  │  终端 UI、编辑器   │
├────────────────────┴────────────────────┤
│  pi-agent-core                          │
│  Agent 循环、工具执行、事件             │
├─────────────────────────────────────────┤
│  pi-ai                                  │
│  Models 集合、多厂商流式 LLM            │
└─────────────────────────────────────────┘

按需取用:只调模型用 pi-ai;要工具循环加 pi-agent-core;要编码助手与会话用 pi-coding-agent;要漂亮终端再加 pi-tui

前置条件

  • Node.js 20+
  • 目标 LLM 厂商的 API Key(如 DeepSeek、OpenAI 等)

安装

安装依赖包:

npm install @earendil-works/pi-ai @earendil-works/pi-agent-core \
  @earendil-works/pi-coding-agent @earendil-works/pi-tui chalk

设置对应厂商的环境变量密钥,例如 DeepSeek 需要在 .env 中写入:

DEEPSEEK_API_KEY=sk-...

Layer 1: pi-ai

核心概念:Provider 拥有模型目录、鉴权与 stream;Models 是已注册 Provider 的集合,按 model.provider 路由请求。

第一次 LLM 调用

使用 createModels() 建空集合,然后调用目标厂商的 provider 工厂注册(如 deepseekProvider())。按需注册利于 tree-shaking。若要一次注册全部内置厂商,可使用 builtinModels()

调用模型时用 models.completeSimple(),传入 model 对象和消息上下文,等待整段结束后返回 AssistantMessage

import { createModels } from "@earendil-works/pi-ai";
import { deepseekProvider } from "@earendil-works/pi-ai/providers/deepseek";

const models = createModels();
models.setProvider(deepseekProvider());

const model = models.getModel("deepseek", "deepseek-v4-flash");

const response = await models.completeSimple(model, {
  systemPrompt: "你是一个简洁的助手。",
  messages: [
    { role: "user", content: "法国的首都是哪里?", timestamp: Date.now() },
  ],
});

for (const block of response.content) {
  if (block.type === "text") console.log(block.text);
}

返回的 content 是类型化块:text / thinking / toolCall;另有 usage(用量)、stopReasonstop | toolUse | length | error | aborted)。

DeepSeek 走 OpenAI Completions 兼容协议(api: "openai-completions"),鉴权读 DEEPSEEK_API_KEY

流式输出

models.streamSimple() 将各厂商的原生流式格式归一成同一套事件:starttext_deltathinking_deltatoolcall_*doneerror。多数场景只关心 text_delta(逐字输出)与 done(最终消息与用量)。

const stream = models.streamSimple(model, {
  systemPrompt: "你是一个简洁的助手。",
  messages: [
    { role: "user", content: "用三句话解释 TCP。", timestamp: Date.now() },
  ],
});

for await (const event of stream) {
  switch (event.type) {
    case "text_delta":
      process.stdout.write(event.delta);
      break;
    case "done":
      console.log(`\nTokens: ${event.message.usage.input} / ${event.message.usage.output}`);
      break;
    case "error":
      console.error(event.error.errorMessage);
      break;
  }
}

// 也可:const final = await stream.result();

切换厂商 / 模型

切换模型只需改 getModel 的模型 id;切换厂商则需先 setProvider 对应工厂,并设置对应的环境变量:

const model = models.getModel("deepseek", "deepseek-v4-flash");
// 换厂商时:先 setProvider(openaiProvider()),并设置 OPENAI_API_KEY
// const model = models.getModel("openai", "gpt-4o");

Thinking / Reasoning

支持扩展思考的模型可通过 reasoning 参数打开(默认关闭),可选值:minimal | low | medium | high | xhigh。开启后流里会出现 thinking_delta 等事件。

const stream = models.streamSimple(model, context, {
  reasoning: "high",
});

自定义 / 本地端点

createProvider() 挂载 OpenAI 兼容端点(Ollama、vLLM 等本地推理服务),而不是只塞一个裸 Model 对象。


Layer 2: pi-agent-core

pi-ai 让你跟 LLM 说话;pi-agent-core 让 LLM 通过工具做事。

Agent 跑标准循环:发消息 → 若有 tool call 则执行 → 把结果塞回上下文 → 再调模型,直到模型不再要工具。

定义工具

工具用 TypeBox 描述参数。schema 先赋给变量再作为 AgentTool<typeof schema> 的泛参,这样 executeparams 有正确类型。

import { Type } from "@earendil-works/pi-ai";
import type { AgentTool } from "@earendil-works/pi-agent-core";

const weatherParams = Type.Object({
  city: Type.String({ description: "城市名" }),
});

const weatherTool: AgentTool<typeof weatherParams> = {
  name: "get_weather",
  label: "天气",
  description: "查询某城市天气",
  parameters: weatherParams,
  execute: async (_id, params, _signal, onUpdate) => {
    return {
      content: [{ type: "text", text: `${params.city}: 晴,22C` }],
      details: { city: params.city },
    };
  },
};

字段含义:name(模型调用名)、label(展示名)、description(何时用)、parameters(校验)、execute(执行)。onUpdate 可流式推送长任务的中间结果。

创建 Agent

import { Agent } from "@earendil-works/pi-agent-core";

const agent = new Agent({
  initialState: {
    systemPrompt: "你是带工具的助手。",
    model,
    tools: [weatherTool],
    thinkingLevel: "off",
  },
  streamFn: models.streamSimple.bind(models),
  // toolExecution: "parallel", // 默认并行;可改为 "sequential"
});

工具执行方式可选 parallel(默认,并行执行所有 tool call)或 sequential(逐个执行)。

事件流

agent.subscribe() 可监听 Agent 生命周期的各类事件:

agent.subscribe((event) => {
  switch (event.type) {
    case "message_update":
      if (event.assistantMessageEvent.type === "text_delta") {
        process.stdout.write(event.assistantMessageEvent.delta);
      }
      break;
    case "tool_execution_start":
      console.log(`Tool: ${event.toolName}`, event.args);
      break;
    case "agent_end":
      console.log("\n完成");
      break;
  }
});

await agent.prompt("东京和伦敦天气怎么样?");

常见事件一览:

事件 触发时机
agent_start / agent_end 整个 prompt 的生命周期
turn_start / turn_end 每一轮 LLM 调用(模型回复 + 工具执行)的开始和结束
message_start / message_update / message_end 消息的流式构建
tool_execution_start / update / end 工具的执行过程

转向与跟进

Agent 跑着时仍可注入用户意图。两种方式:

  • steer:打断——当前工具结束后注入,跳过剩余待执行工具
  • followUp:不打断——等本轮自然结束后再问(仍属同一次 prompt)
const running = agent.prompt("东京和伦敦天气怎么样?");

// steer:打断——跳过剩余工具直接改做别的
agent.steer({
  role: "user",
  content: "别查天气了,改读 README。",
  timestamp: Date.now(),
});

// followUp:不打断——等本轮结束后再追问
agent.followUp({
  role: "user",
  content: "再总结一下。",
  timestamp: Date.now(),
});

await running; // 会等 followUp 那一轮也跑完

两者只是往队列塞消息,消费发生在当前 prompt() 的 agent loop 里。因此必须在 await prompt() 之前(与之并行)调用——若等 prompt resolve 后再调,消息会积压且不会自动开下一轮(除非再 continue() 或新开一次 prompt)。

运行时改状态

旧版的 setModel / setSystemPrompt 等方法已去掉;直接改 agent.state(下一 turn 生效):

agent.state.model = anotherModel;
agent.state.thinkingLevel = "high";
agent.state.systemPrompt = "新指令";
agent.state.tools = [...newTools];  // 顶层数组会拷贝一份再存
agent.state.messages = trimmed;

还可在构造时或之后挂 beforeToolCall / afterToolCall 做权限拦截或结果改写。


Layer 3: pi-coding-agent

pi-agent-core 给循环;pi-coding-agent 给生产向编码 Agent:内置文件工具、会话持久化、压缩、扩展。内部仍基于 agent-core。

多数场景从这里起步;只有当你不需要内置编码工具/会话系统时,才直接用 agent-core。

内置工具

默认启用四个文件操作工具;另有可选探索工具:

工具 作用
read 读文件与图片;支持 offset/limit
bash 在工作目录执行命令
edit 精确替换文件中的一段文本
write 写文件(可创建父目录)
grep 正则搜内容(ripgrep,尊重 .gitignore)
find 按 glob 找文件
ls 列目录

工厂函数可绑定特定工作目录,或自定义 operations(例如 Docker / 远程文件系统):

import {
  createCodingTools,
  createReadOnlyTools,
  createBashTool,
} from "@earendil-works/pi-coding-agent";

createCodingTools("/path/to/workspace");   // read, bash, edit, write
createReadOnlyTools("/path/to/workspace"); // read, grep, find, ls
createBashTool("/workspace", {
  operations: { exec: async (cmd, cwd, opts) => runInDocker(cmd, cwd, opts) },
});

createAgentSession 里,启用哪些工具用名字白名单 tools: string[],不是传入 Tool 对象数组。

最小会话

createAgentSession 创建会话,需传入 modelmodelRuntimesessionManagersessionManager 可以是内存模式(SessionManager.inMemory()),不落盘:

import {
  createAgentSession,
  ModelRuntime,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const modelRuntime = await ModelRuntime.create();
const model = modelRuntime.getModel("deepseek", "deepseek-v4-flash");

const { session } = await createAgentSession({
  model,
  modelRuntime,
  sessionManager: SessionManager.inMemory(),
});

session.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await session.prompt("当前目录有哪些文件?");
session.dispose();

选模型、工具白名单、自定义工具

createAgentSession 中通过 tools 数组指定启用哪些内置工具(名字白名单),通过 customTools 传入用 defineTool 定义的自定义工具。defineTool 用于保留 TypeBox 参数推断;也可在 extension 里注册。

import { Type } from "@earendil-works/pi-ai";
import {
  createAgentSession,
  defineTool,
  ModelRuntime,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const deployTool = defineTool({
  name: "deploy",
  label: "部署",
  description: "部署到指定环境",
  parameters: Type.Object({
    environment: Type.String({ description: "staging / production" }),
  }),
  execute: async (_id, params) => ({
    content: [{ type: "text", text: `已部署到 ${params.environment}` }],
    details: {},
  }),
});

const modelRuntime = await ModelRuntime.create();
const model = modelRuntime.getModel("deepseek", "deepseek-v4-flash");

const { session } = await createAgentSession({
  model,
  thinkingLevel: "off",
  modelRuntime,
  sessionManager: SessionManager.inMemory(),
  tools: ["read", "bash", "edit", "write", "deploy"], // 名字白名单
  customTools: [deployTool],
});

会话持久化

会话存成 JSONL,条目带 id / parentId,形成树,便于分支与「从某点重试」。

SessionManager.inMemory();                 // 内存,不落盘
SessionManager.create(process.cwd());      // 在 ~/.pi/agent/sessions/ 下新建
SessionManager.open("/path/to/session.jsonl");
SessionManager.continueRecent(process.cwd());
await SessionManager.list(process.cwd());

底层能力(自建多通道路由时会用到):buildSessionContext()getLeafEntry()branch(entryId)appendMessage()getTree()

Compaction(压缩)

长对话会顶满上下文窗口。coding-agent 可把旧消息摘要掉、保留最近轮次:

import { estimateTokens } from "@earendil-works/pi-coding-agent";

const total = session.messages.reduce((s, m) => s + estimateTokens(m), 0);
if (total > 100_000) {
  await session.compact("保留文件路径与代码改动");
}

默认开启 auto-compaction;完整历史仍在 JSONL 里,只压缩内存上下文。

Extensions(扩展概览)

工具让模型做事;扩展改 Agent行为(模型看不见)。典型用途:裁剪旧 tool 结果、替换压缩逻辑、拦截危险工具、注入上下文。

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function myExtension(pi: ExtensionAPI): void {
  pi.on("tool_call", async (event) => {
    if (event.toolName === "bash" && String(event.input).includes("rm -rf")) {
      return { block: true, reason: "危险命令已拦截" };
    }
  });

  pi.registerCommand("stats", {
    description: "显示会话统计",
    handler: async (_args, ctx) => {
      const stats = ctx.session.getSessionStats();
      console.log(stats);
    },
  });
}

通过 DefaultResourceLoaderextensionFactories / additionalExtensionPaths 挂载。事件还包括 contextsession_before_compactbefore_agent_startsession_start 等。


做一个能用的助手

把会话持久化、自定义工具、REPL 串起来,就是一个完整的编码助手。核心流程:

  1. SessionManager.open 持久化对话到 JSONL 文件
  2. ModelRuntime 选择可用模型
  3. defineTool 注册自定义工具(如 web search)
  4. createAgentSession 中用 tools 白名单和 customTools 注册工具
  5. session.subscribe 监听事件,打印流式文本与工具调用
  6. 启动 REPL 循环;提供 exit 退出、new 重置会话的命令

重置会话时,需要 dispose 旧 session + 新建 SessionManager + 重建 session。当前 AgentSession 上没有旧版的 newSession(),需要整会话替换或用 createAgentSessionRuntime

示例交互效果:

PI Assistant
  Model: deepseek-v4-flash
  Tools: read, bash, edit, write, web_search

You: 这个项目做什么?先看 README 和 package.json。

  [read] README.md
  [read] package.json

这是一个演示 pi-* 分层用法的 TypeScript 项目……

生产向要点

嵌入自己的产品时,优先显式构造 ModelRuntime,指定自定义的鉴权路径和模型配置路径:

const modelRuntime = await ModelRuntime.create({
  authPath: "/my/app/auth.json",
  modelsPath: "/my/app/models.json",
});
modelRuntime.setRuntimeApiKey("deepseek", process.env.DEEPSEEK_API_KEY!);

const model = modelRuntime.getModel("deepseek", "deepseek-v4-flash");

const { session } = await createAgentSession({
  model,
  modelRuntime,
  sessionManager: SessionManager.create(cwd, customSessionDir),
  // resourceLoader / settingsManager / tools / customTools …
});

需要 /new/resume/fork 等会话替换流时,使用 createAgentSessionRuntime。替换后要重新 subscribe / bindExtensions 到新的 runtime session。

OAuth、多 Provider 订阅、settings 等细节见各包 README。


加上终端 UI(pi-tui)

纯 readline 版没有 Markdown 渲染、自动补全和流畅的流式刷新。pi-tui 用差分渲染 + 同步输出做无闪烁终端 UI。

TUI 的核心架构

  1. new TUI(new ProcessTerminal()) 创建 TUI 实例,addChild 搭建组件树,setFocus(editor) 设置焦点,tui.start() 启动渲染循环
  2. 主题MarkdownTheme / EditorTheme 都是 (text) => ANSI 着色函数(常用 chalk 实现)
  3. 布局约定Editor 固定在 children 末尾;新消息用 children.splice(len - 1, 0, comp) 插在输入框上方
  4. 事件驱动渲染:通过 session.subscribe 把 agent 生命周期映射成增删/更新组件,每次改完后调 tui.requestRender()

TUI 核心组件

组件 用途
Text 静态文本行
Markdown 渲染 Markdown 内容(支持自定义主题),可动态更新文本
Editor 多行输入编辑器,支持自动补全
Loader 加载动画(如 "Thinking...")

流式渲染核心模式

流式输出时,累加 streamingText,在同一个 Markdown 实例上调用 .setText() 更新内容,而不是每次 delta 都新建组件:

streamingText += event.assistantMessageEvent.delta;
if (!streamingMarkdown) {
  streamingMarkdown = new Markdown(streamingText, 1, 0, markdownTheme);
  children.splice(children.length - 1, 0, streamingMarkdown);
} else {
  streamingMarkdown.setText(streamingText);
}
tui.requestRender();

注意:Markdown 第五个参数是 DefaultTextStyle 对象(如 { color: (s) => chalk.bold(s) }),不是函数。

事件与 UI 的映射

  • agent_start:插入 Loader 组件
  • 首个 text_delta / 工具开始:移除 Loader
  • text_delta 持续到达:更新 Markdown 组件文本
  • tool_execution_start:插入一行 Text 提示工具名和参数
  • agent_end:清理状态,恢复编辑器可提交
  • 退出:通过 matchesKey(data, "ctrl+c")/exit 命令触发 tui.stop()

编辑器的自动补全

Editor 可配置 CombinedAutocompleteProvider,支持命令(如 /exit/new)和文件路径的 Tab 补全:

editor.setAutocompleteProvider(
  new CombinedAutocompleteProvider(
    [
      { name: "new", description: "重置会话" },
      { name: "exit", description: "退出" },
    ],
    process.cwd(),
  ),
);

概念总结

核心概念 适用场景
pi-ai Models、Provider、streamSimple、completeSimple 只需调用 LLM,不需要工具循环
pi-agent-core Agent、AgentTool、事件订阅、steer/followUp 需要 LLM 使用工具,但不需内置文件工具
pi-coding-agent createAgentSession、SessionManager、内建工具、扩展 需要完整的编码助手与会话管理
pi-tui TUI、Markdown、Editor、Loader、差分渲染 需要漂亮的终端交互界面

下一步

  • 深入学习各包的 README 文档
  • 参考上游 monorepo 的更多示例
  • 生产环境集成可参考 OpenClaw 等项目

附录:旧 API → 新 API 映射

旧 API 新 API(0.82 @earendil-works
全局 getModel(p, id) models.getModel(p, id)ModelRuntime / getBuiltinModel
顶层导入 streamSimple / completeSimple models.streamSimple / models.completeSimple
streamFn: streamSimple streamFn: models.streamSimple.bind(models)
直接赋值 session.agent.streamFn 已删除;由 ModelRuntime / session 内部自动接线
tools: [allBuiltInTools.read, …] tools: ["read", "bash", …](名字白名单)
customToolsAgentTool 对象 defineTool(...) 返回 ToolDefinition
AuthStorage + ModelRegistry 主路径 ModelRuntime.create() 主路径
@mariozechner/pi-* @earendil-works/pi-*
@earendil-works/pi-ai 根上的旧全局符号 过渡期 @earendil-works/pi-ai/compat(将移除)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment