PI Agent 开发一个生产级 Harness

基于 PI 开发自有 Agent Harness 的工程验证,不是把 PI 当成新的 LangChain 去背 API。我们会用 @earendil-works/pi-ai@0.85.1 和 @earendil-works/pi-agent-core@0.85.1,从零走一遍从模型请求到可恢复运行的装配过程,重点观察哪些能力由 PI 提供,哪些接口需要应用补齐,以及这套方案放进 NestJS 后是否可控。当然这用不 PI 的所有能力。

先看同类产品。LangChain 的 create_agent、LangGraph、DeepAgents 和 Cherry Studio 用法不同,模型接入、工具注册、消息组织、运行循环、状态保存和可观测性这些底层职责却基本一致:

阶段 共同职责 PI 0.85.1 的主要入口
连接模型 Provider、协议、认证、模型目录 Models、Provider、Model
组织上下文 消息、system prompt、工具 schema Message、systemPrompt、AgentHarnessTool
驱动执行 模型与工具之间的循环 agentLoop(),生产侧由 AgentHarness 承接
扩展运行 middleware、hook、event harness.hooks、harness.events
保存状态 session、checkpoint、恢复 SessionRepo、Session、AgentLane

PI 0.85.1 这条线常用的包不多:

  • @earendil-works/pi-ai 负责模型、认证、协议 adapter 和消息类型。
  • @earendil-works/pi-agent-core 负责 Agent Loop、Agent、AgentHarness、Session、Tool、Skill 和运行生命周期。
  • @earendil-works/pi-telemetry 提供可选、厂商中立的遥测契约,不接也能运行。

PI 仓库里还有 CLI、终端交互和运行器编排等能力。它们主要服务本地命令行产品。更常见的链路是浏览器发起请求,NestJS 持有 Provider API Key,再通过 PI 调模型。本文只引入 pi-ai、pi-agent-core 和必要的业务适配代码。

本文沿着一条生产中轴推进:模型连接 -> Agent Loop -> Agent 与 AgentHarness -> 消息和 system prompt -> Tool、MCP、Skill、RAG -> 生命周期与 Session -> NestJS。

集成模型连接

模型连接可以分成两类:PI 已经内置目录的标准 Provider,以及企业网关、私有部署或兼容服务使用的自定义 Provider。

无论哪一类,一次请求都要凑齐四样东西:知道协议、地址和认证方式的 Provider,能从目录里查到的 Model,请求时解析 API Key 或 OAuth 的认证声明,以及与 Model.api 匹配的真实服务端协议。

认识 Model、Provider 和 Models

这三个对象容易混在一起,先按职责分开看:

对象 作用 是否可以直接手写
Model 描述一个具体模型,例如百炼的 qwen-plus 可以,但只有注册到 Provider 后才能被 Models 查找和调用
Provider 绑定 Provider ID、地址、认证方式和协议 adapter 可以用 createProvider() 创建
Models Provider 注册表和请求入口,负责查找模型、解析认证、转发请求 用 createModels() 创建

Model 可以单独声明,但要让 Models 负责查找、认证和请求,它必须注册到一个 Provider。getModel(providerId, modelId) 只扫描内存里的模型目录,不会发网络请求,也不会根据模型名临时推断 URL。

连接内置 Provider

0.85.1 的内置目录包含 OpenAI、Anthropic、Google、DeepSeek、Groq、Mistral、xAI、OpenRouter 等常见 Provider。下面直接用工厂注册 OpenAI 和 Anthropic,不传 baseUrl,也不手写请求路径:

ts 复制代码
// src/agent/builtin.models.ts
import { createModels, type Models } from '@earendil-works/pi-ai';
import { anthropicProvider, openaiProvider } from '@earendil-works/pi-ai/providers/all';

export function createBuiltinModels(): Models {
  const models = createModels();

  // 工厂内部已经包含 baseUrl、认证环境变量和协议 adapter。
  models.setProvider(openaiProvider());
  models.setProvider(anthropicProvider());

  return models;
}

const models = createBuiltinModels();
const openaiModel = models.getModel('openai', 'gpt-4.1');
const claudeModel = models.getModel('anthropic', 'claude-haiku-4-5');

if (!openaiModel || !claudeModel) {
  throw new Error('内置模型未注册');
}

const stream = models.streamSimple(openaiModel, {
  messages: [{ role: 'user', content: '你好', timestamp: Date.now() }]
});

for await (const event of stream) {
  if (event.type === 'text_delta') {
    process.stdout.write(event.delta);
  }
}

这两个工厂不需要 URL,是因为源码里已经写好了:openaiProvider() 使用 https://api.openai.com/v1、OPENAI_API_KEY 和 Responses adapter;anthropicProvider() 使用 https://api.anthropic.com、Anthropic API Key 或 OAuth 和 Messages adapter。对应的 Model.baseUrl 也保存在内置模型目录里,所以 getModel() 取出来的对象可以直接请求。

内置目录不等于支持某家厂商的所有接口。百炼标准 API 不在这个版本的直接目录中,Qwen Token Plan 又是另一套端点和认证方式,不能混用。因此百炼要通过自定义 Provider 接入。

接入百炼 Provider

下面这段代码可以直接作为 src/agent/bailian.models.ts。百炼的 Compatible Mode 使用 OpenAI Chat Completions 协议,因此 baseUrl 写到 /compatible-mode/v1,openAICompletionsApi() 会在后面拼接 /chat/completions:

ts 复制代码
// src/agent/bailian.models.ts
import {
  createModels,
  createProvider,
  envApiKeyAuth,
  type Model,
  type Models
} from '@earendil-works/pi-ai';
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';

const baseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
const qwenPlus: Model<'openai-completions'> = {
  id: 'qwen-plus',
  name: 'Qwen Plus',
  api: 'openai-completions',
  provider: 'bailian',
  // API 根路径,openAICompletionsApi 会在这里后面拼接 /chat/completions。
  baseUrl,
  reasoning: false,
  input: ['text'],
  // 示例价格设为 0;生产环境换成百炼官方价格,usage.cost 才有正确结果。
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  contextWindow: 131_072,
  maxTokens: 8_192,
  // 百炼兼容端点不支持 OpenAI 的 store、developer role 等字段时,明确关掉自动探测。
  compat: {
    supportsStore: false,
    supportsDeveloperRole: false,
    supportsReasoningEffort: false,
    supportsStrictMode: false,
    maxTokensField: 'max_tokens'
  }
};

export function createBailianModels(): Models {
  const models = createModels();

  models.setProvider(
    createProvider({
      id: 'bailian',
      name: '阿里云百炼',
      baseUrl,
      // envApiKeyAuth 只声明 Key 的来源,不会在创建 Provider 时读取或保存 Key。
      auth: { apiKey: envApiKeyAuth('百炼 API Key', ['DASHSCOPE_API_KEY']) },
      models: [qwenPlus],
      api: openAICompletionsApi()
    })
  );

  return models;
}

const models = createBailianModels();
const model = models.getModel('bailian', 'qwen-plus');

if (!model) {
  throw new Error('qwen-plus 未注册到百炼 Provider');
}

const stream = models.streamSimple(model, {
  messages: [{ role: 'user', content: '你好,介绍一下你自己。', timestamp: Date.now() }]
});

for await (const event of stream) {
  if (event.type === 'text_delta') {
    process.stdout.write(event.delta);
  }
}

envApiKeyAuth(name, envVars) 的参数和作用很直接:

  • name 给登录界面和认证状态展示,
  • envVars 是按顺序查找的候选环境变量。每次请求前,Models 会优先读取已保存的 credential,再回退到 DASHSCOPE_API_KEY。如果要在服务端保存多租户 Key,可以提供自定义 CredentialStore,不要把 Key 写进 Model 或业务代码。

Model 里几个字段会直接影响运行:

  • api 决定使用哪套协议 adapter,本例是 openai-completions。
  • provider 必须和 Provider.id 一致,否则 Models 找不到归属。
  • baseUrl 是本次请求实际使用的 API 根路径。
  • contextWindow 和 maxTokens 参与上下文预算、压缩和输出限制。
  • input 声明模型是否接受图片,reasoning 声明是否支持思考。
  • compat 用于覆盖 OpenAI 兼容服务的字段差异,避免把 OpenAI 默认字段发给百炼。

openAICompletionsApi() 表示 PI 会按 Chat Completions 协议发送请求并解析响应。远端至少要支持消息角色、tool call、流式 chunk、finish_reason、token usage 和错误结构。只会返回一段文本,不等于兼容 Agent 所需的协议。

使用 openAIResponsesApi 连接 Responses 模型

openAIResponsesApi() 返回一个 ProviderStreams,负责按 OpenAI Responses 协议请求模型,并把流式响应转换成 PI 消息:

ts 复制代码
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy';
import type { Model, ProviderStreams } from '@earendil-works/pi-ai';

const api: ProviderStreams = openAIResponsesApi();
const model = {
  id: 'responses-demo',
  name: 'Responses demo',
  api: 'openai-responses',
  provider: 'responses-demo',
  baseUrl: 'https://api.openai.com/v1',
  reasoning: false,
  input: ['text'],
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  contextWindow: 128_000,
  maxTokens: 4_096
} satisfies Model<'openai-responses'>;

const stream = api.streamSimple(
  model,
  {
    systemPrompt: '你是一个简洁的技术助手。',
    messages: [{ role: 'user', content: '解释一下 Agent Loop。', timestamp: Date.now() }]
  },
  { apiKey: process.env.OPENAI_API_KEY }
);

for await (const event of stream) {
  if (event.type === 'text_delta') {
    process.stdout.write(event.delta);
  }
}

// result() 返回终态 AssistantMessage,content 可能包含文本、thinking 或 tool call。
const message = await stream.result();
console.log(message.stopReason, message.usage.totalTokens);

ProviderStreams 的核心方法:

方法 参数 返回值 作用
stream() Model、Context、完整 StreamOptions AssistantMessageEventStream 使用完整协议参数发起请求
streamSimple() Model、Context、SimpleStreamOptions AssistantMessageEventStream 使用简化参数发起请求
fetchDeferred() Model、DeferredHandle AssistantMessageEventStream 查询远端异步任务,可选能力
cancelDeferred() Model、DeferredHandle Promise<void> 取消远端异步任务,可选能力

AssistantMessageEventStream 会依次产生 start、text_*、thinking_*、toolcall_*、done 或 error 事件。终态 AssistantMessage 中,content 是模型输出,usage 用于计费和上下文压缩,stopReason 决定 Agent Loop 是否继续调用工具。

集成 Agent

模型连接只解决一次请求。真正让 Agent 动起来的是工具调用闭环:模型返回 toolCall,运行时执行工具,再把 ToolResultMessage 放回消息历史,模型据此继续判断。

理解 Agent Loop 的执行过程

LangChain 的 create_agent 和 PI 的 Agent、AgentHarness 在使用层面很像:都接收模型、prompt、工具和中间件或 Hook。但它们的底层执行机制不同,不能因为接口看起来相似就按同一种方式理解。

LangChain 的 create_agent 建立在 LangGraph 的图运行时之上,内部通常表现为 model 节点、tool 节点和条件边组成的状态图。图负责状态流转、条件路由、interrupt 和 checkpoint,后续节点走哪条路由由当前图状态决定。

PI 没有为每个 Agent 创建一张状态图。它的心脏是 agent_loop:本轮请求模型,读取 AssistantMessage.content 中的 toolCall,执行工具并写回 ToolResultMessage,然后再次请求模型。只要上下文里还有工具结果或后续消息,循环就继续。这里没有图节点和条件边,控制逻辑就是 while 循环加若干终止判断。

这决定了两边的状态和扩展语义:LangGraph 更关注 graph state、节点路由和 checkpoint;PI 更关注 AgentMessage[]、运行中的 Context、Hook 和 Session。两者都能让模型调用工具,但 PI 不会把一次对话自动编译成工作流图。

PI 把底层循环放在 @earendil-works/pi-agent-core,并从包根入口导出:

ts 复制代码
import {
  agentLoop,
  agentLoopContinue,
  type AgentContext,
  type AgentLoopConfig,
  type AgentMessage,
  type StreamFn
} from '@earendil-works/pi-agent-core';

// 新提示词进入上下文,并启动 Loop。
export function startLoop(
  prompts: AgentMessage[],
  context: AgentContext,
  config: AgentLoopConfig,
  signal: AbortSignal,
  streamFn: StreamFn
) {
  return agentLoop(prompts, context, config, signal, streamFn);
}

// 不新增提示词,从当前上下文继续,常用于重试遗留的模型请求。
export function continueLoop(
  context: AgentContext,
  config: AgentLoopConfig,
  signal: AbortSignal,
  streamFn: StreamFn
) {
  return agentLoopContinue(context, config, signal, streamFn);
}

这两个函数返回 EventStream<AgentEvent, AgentMessage[]>。agentLoopContinue() 有一个很关键的约束:上下文最后一条消息必须能转换成 user 或 toolResult,否则下一轮模型请求会不合法。

0.85.1 的 runLoop() 核心不是一次递归,而是双层 while。下面把源码行为压缩成便于阅读的伪代码:

ts 复制代码
while (true) {
  let hasMoreToolCalls = true;

  while (hasMoreToolCalls || pendingMessages.length > 0) {
    注入 steering 消息
    调用模型并消费 AssistantMessageEventStream

    if (stopReason 是 error 或 aborted) return

    toolCalls = AssistantMessage.content 中的 toolCall
    if (toolCalls.length > 0) {
      if (stopReason 是 length) {
        所有工具都不执行,生成错误 ToolResultMessage
      } else {
        执行工具并生成 ToolResultMessage
      }

      hasMoreToolCalls = 这批工具没有要求 terminate
    } else {
      hasMoreToolCalls = false
    }

    if (shouldStopAfterTurn()) return
  }

  followUp = 获取 follow-up 消息
  if (followUp.length > 0) pendingMessages = followUp
  else break
}

几个判断直接决定生产行为:

  • 内层循环用 hasMoreToolCalls || pendingMessages.length > 0 判断是否还要跑。模型没有工具调用时,内层退出;只有存在 follow-up 时,外层才会继续。
  • stopReason === 'length' 时,回复已经被输出上限截断,工具参数可能不完整。PI 不会赌参数能不能用,而是全部转成错误结果,让模型重新发起调用。
  • 同一批工具默认可以并行;只要其中一个工具声明 executionMode: 'sequential',整批工具就会按顺序执行。
  • 每个工具结果都靠 toolCallId 与原始调用对齐。这个字段丢了,历史会被模型端拒绝,或者被错误地关联到别的调用。
  • shouldStopAfterTurn() 可以在当前 turn 正常结束后提前收口,例如预算耗尽、用户取消,或者已经拿到最终业务结果。

Loop 处理的是 AgentMessage,真正发给模型前才通过 convertToLlm() 转成 LLM Message[]。这层转换让应用可以在会话里保存 UI 条目、系统事件和业务状态,同时只把合法消息交给模型。

生产代码通常不直接调用 agentLoop(),而是使用下一节的 AgentHarness。Harness 负责把持久化、工具调度、Hook、取消和恢复接起来,Loop 仍然是它内部最核心的执行机制。

集成 Agent 与 AgentHarness

Agent 和 AgentHarness 都从 @earendil-works/pi-agent-core 根入口导出,但它们不是同一种东西。

Agent 是类,用 new Agent() 创建。它持有当前 transcript、模型、工具和 active run,适合 CLI、一次性脚本或由应用自己调度的短生命周期任务。它不负责数据库会话、跨进程恢复和租户租约。

ts 复制代码
import { Agent } from '@earendil-works/pi-agent-core';
import { createBailianModels } from './bailian.models';

const models = createBailianModels();
const model = models.getModel('bailian', 'qwen-plus');

if (!model) {
  throw new Error('模型不存在或未配置');
}

const agent = new Agent({
  // Models.streamSimple 已经满足 Agent 需要的 StreamFn,认证由 Models 处理。
  streamFn: (selectedModel, llmContext, options) => {
    return models.streamSimple(selectedModel, llmContext, options);
  },
  initialState: {
    model,
    systemPrompt: '你是一个简洁的技术助手。',
    messages: [],
    tools: []
  }
});

await agent.prompt('用一句话解释什么是 Agent Loop。');
console.log(agent.state.messages.at(-1));

AgentHarness 不是类,不能写 new AgentHarness()。你拿到的是带 create() 方法的工厂对象,创建后得到的才是绑定某个 Session 的 harness。它不是 Agent 的父子类,也不保证内部一定持有一个 Agent。两者的关系更准确地说是:Agent 是内存运行壳,AgentHarness 是带持久化和恢复语义的运行层。

text 复制代码
SessionRepo.create/open()
  -> Session
  -> AgentHarness.create({ session, models, model, tools, ... })
  -> { harness, open }
  -> harness.lane('main', context)
  -> AgentLane
  -> lane.prompt('用户消息', undefined, context)

生产主线选择 Harness,原因是它解决 Agent 不负责的事情:

  • Session、Lane 和 operation 有持久化身份,进程重启后还能找到未完成的工作。
  • 工具、system prompt 和 stream options 会按 turn 生成快照,运行中的配置变化有明确边界。
  • Hook 和 Event 分别处理可修改的流程与旁路观测。
  • 同一个 Lane 的并发 operation 会被识别,不会让两个请求同时写坏一条会话。

先把最小 Harness 骨架搭出来:

ts 复制代码
import {
  AgentHarness,
  BACKGROUND_CONTEXT,
  JsonlSessionRepo,
  withAbortSignal,
  type Context
} from '@earendil-works/pi-agent-core';
import { NodeExecutionEnv } from '@earendil-works/pi-agent-core/node';
import { createBailianModels } from './bailian.models';

// 以下三项是应用级资源,可以跟随进程生命周期复用。
const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() });
const models = createBailianModels();
const sessions = new JsonlSessionRepo({
  fileSystem: executionEnv,
  sessionsRoot: '/data/pi-sessions'
});

export async function createHarness(requestSignal: AbortSignal) {
  // Context 是本次请求的运行作用域,取消信号会继续传给 Provider、Hook 和 Tool。
  const context: Context = withAbortSignal(requestSignal, BACKGROUND_CONTEXT);
  const session = await sessions.create({ cwd: process.cwd() }, context);
  const model = models.getModel('bailian', 'qwen-plus');

  if (!model) {
    throw new Error('模型不存在或未配置');
  }

  const { harness, open } = await AgentHarness.create(
    {
      session,
      models,
      model,
      systemPrompt: '你是一个简洁的技术助手。',
      tools: []
    },
    context
  );
  const lane = await harness.lane('main', context);

  return { context, session, harness, lane, open };
}

这里最容易混淆的几个词可以这样理解:

名词 通俗解释 作用域
SessionRepo 会话仓库,负责创建、打开、列出和删除会话 应用级,通常单例
Session 一段可以持久化的对话及其分支、operation 状态 会话级,不能跨用户共享
Context 本次执行的身份、取消信号和 telemetry 父级等运行环境 请求或子任务级
AgentHarness 把 Session、模型、工具和 Hook 组装成可恢复运行 会话级
AgentLane Session 内一条串行执行线,默认叫 main 会话内的运行线

Context 不是业务用户对象,别把 tenant、用户角色和订单信息塞进它。业务数据应放进 toolContext,后面绑定工具时会看到两者的分工。

组织消息与 system prompt

PI 在调用模型前,会通过 toProviderMessages 把运行期的 AgentMessage[] 转成 LLM 可理解的 Message[]。默认转换后的 LLM 消息只有三种:

ts 复制代码
import type { Message, UserMessage } from '@earendil-works/pi-ai';

// 这是传给 Provider 的消息联合类型。
type LlmMessage = Message;

const userMessage: UserMessage = {
  role: 'user',
  content: '帮我查一下订单 A1001 的物流状态。',
  timestamp: Date.now()
};
类型 关键字段 作用
UserMessage role: "user"、content、timestamp 用户文本或图片
AssistantMessage content、provider、model、usage、stopReason 模型文本、thinking 和 tool call
ToolResultMessage toolCallId、toolName、content、isError 把工具结果关联回调用

toolCall 不是独立消息,而是 AssistantMessage.content 里的一种内容块。工具执行后,Harness 会生成对应的 ToolResultMessage,下一轮模型依靠 toolCallId 对齐调用和结果。

Session 里可以保存 UI 条目、系统事件或应用自己的 CustomMessage。这些内容进入模型前必须经过 toProviderMessages,不要让业务代码直接拼 provider、usage、toolCallId 这些运行字段。

PI 没有 SystemMessage。system prompt 和消息数组分开传递,而且可以写成函数,在每次运行时按 toolContext 生成:

ts 复制代码
import {
  AgentHarness,
  convertToLlm,
  type AgentMessage,
  type Context,
  type Session
} from '@earendil-works/pi-agent-core';
import type { Api, Message, Model, Models } from '@earendil-works/pi-ai';

interface AppContext {
  tenantName: string;
  role: 'user' | 'admin';
}

interface CreateHarnessInput {
  session: Session;
  models: Models;
  model: Model<Api>;
  auth: AppContext;
  context: Context;
}

export function createTenantHarness(input: CreateHarnessInput) {
  return AgentHarness.create<AppContext>(
    {
      session: input.session,
      models: input.models,
      model: input.model,
      tools: [],
      toolContext: input.auth,
      // 这里生成的是本次 turn 的系统提示词,不是一条会话消息。
      systemPrompt: ({ tenantName, role }) => {
        const permission = role === 'admin' ? '可以查询管理数据' : '只能查询本人数据';
        return `你是企业内部知识助手。\n当前租户:${tenantName}\n权限:${permission}`;
      },
      toProviderMessages: (messages: AgentMessage[]): Message[] => {
        // 把 user、assistant、toolResult 和 PI 已知的应用消息统一转成模型能接收的 Message。
        return convertToLlm(messages);
      }
    },
    input.context
  );
}

convertToLlm() 会保留 user、assistant、toolResult,并把 PI 内置的压缩摘要、branch summary 等消息转成模型可读的 user 消息。项目自己扩展的消息类型如果没有对应转换规则,要先在这里转成合法内容,或者实现自己的 toProviderMessages;直接做类型断言只能骗过 TypeScript,请求仍会被 Provider 拒绝。

system prompt 只放稳定身份、规则和工具索引,不要把完整 Skill 正文、整批 RAG 文档、API Key 或数据库连接串塞进去。

发送消息统一走 Lane。lane.prompt() 返回 RunResult,不是直接返回一条 AssistantMessage:

ts 复制代码
import type { AgentLane, Context } from '@earendil-works/pi-agent-core';

export async function promptLane(lane: AgentLane, context: Context) {
  const result = await lane.prompt('总结当前会话。', undefined, context);

  if (!result.ok) {
    // LaneBusy、InvalidMessage、Closed 等预期失败都在这里处理。
    return { ok: false as const, error: result.error };
  }

  // value 是 operation 的最终记录;消息正文仍要从 Session 或事件流中读取。
  return { ok: true as const, operation: result.value };
}

定义并绑定工具

工具从 @earendil-works/pi-agent-core 根入口定义,参数 schema 使用 PI 的 TypeBox 接口。下面先定义应用自己的服务和业务上下文,再把它包装成 Harness Tool:

ts 复制代码
import type {
  AgentHarnessTool,
  AgentHarnessToolInvocation,
  Context
} from '@earendil-works/pi-agent-core';
import { Type } from '@earendil-works/pi-ai';

interface AppContext {
  tenantId: string;
  role: 'user' | 'admin';
}

interface Order {
  id: string;
  status: string;
  amount: number;
}

interface OrderService {
  getOrder(orderId: string, tenantId: string, signal?: AbortSignal): Promise<Order>;
}

const orderParams = Type.Object({
  orderId: Type.String({ description: '订单号' })
});

export function createOrderTool(orderService: OrderService): AgentHarnessTool<AppContext, typeof orderParams> {
  return {
    name: 'get_order',
    label: '查询订单',
    description: '根据订单号查询订单状态、金额和物流信息。',
    parameters: orderParams,
    execute: async (
      toolCallId: string,
      { orderId },
      onUpdate,
      toolContext,
      invocation: AgentHarnessToolInvocation,
      context: Context
    ) => {
      // 工具可以先把中间进度推给 UI,不必等整个业务查询结束。
      onUpdate({ content: [{ type: 'text', text: '正在查询订单...' }], details: {} });
      const order = await orderService.getOrder(orderId, toolContext.tenantId, context.abortSignal);

      return {
        // content 会进入下一轮模型上下文,details 只给 UI、日志或审计使用。
        content: [{ type: 'text', text: JSON.stringify(order) }],
        details: { toolCallId, operationId: invocation.operationId, order }
      };
    }
  };
}

这里几个名字第一次出现时最容易混:

text 复制代码
tools             Harness 手里有哪些工具定义
activeToolNames   当前 turn 允许模型看到哪些工具
toolContext       当前 turn 随工具调用一起传入的业务上下文快照
Context           取消、telemetry 和运行值域,主要给运行时使用
invocation        operation、turn、memo 等恢复信息

tools 和 toolContext 都是 AgentHarness.create() 的配置,但职责不同。tools 里的每个工具都可以写死,toolContext 可以按请求生成。execute() 的六个参数也有明确用途:

参数 类型 作用
toolCallId string 关联调用和结果
params Static<typeof parameters> 已经过 schema 校验的参数
onUpdate AgentHarnessToolUpdateCallback 上报流式进度和恢复检查点
toolContext TContext 当前 turn 的业务上下文快照
invocation AgentHarnessToolInvocation operation、turn、memo 和恢复信息
context Context 取消信号和运行期值域

工具返回值里的 content 是模型的输入,details 是应用侧结构化数据。不要把密钥、数据库连接串或不适合给模型看的原始数据放进 content。

批量绑定工具时,activeToolNames 决定模型当前能看到什么:

ts 复制代码
import { AgentHarness } from '@earendil-works/pi-agent-core';
import type { Context, Session } from '@earendil-works/pi-agent-core';
import type { Api, Model, Models } from '@earendil-works/pi-ai';

interface HarnessInput {
  session: Session;
  models: Models;
  model: Model<Api>;
  context: Context;
  orderService: OrderService;
}

export async function createOrderHarness(input: HarnessInput) {
  const orderTool = createOrderTool(input.orderService);
  const { harness } = await AgentHarness.create<AppContext>(
    {
      session: input.session,
      models: input.models,
      model: input.model,
      tools: [orderTool],
      activeToolNames: ['get_order'],
      toolContext: { tenantId: 'tenant-a', role: 'user' }
    },
    input.context
  );

  return harness;
}

运行中可以调用 harness.setTools() 替换工具定义,也可以调用 lane.setActiveTools() 调整当前 Lane 的可见工具。配置变化后,新的 turn 会拿到新的快照。

隐藏工具不等于授权。模型看不到某个工具,并不意味着攻击者不能伪造 tool call,真正的权限判断应该放在 before_tool:

ts 复制代码
import type { AgentHarness } from '@earendil-works/pi-agent-core';

interface RequestAuth {
  userId: string;
}

interface PermissionService {
  canCall(auth: RequestAuth, toolName: string, args: unknown): Promise<boolean>;
}

export function registerPermissionHook(
  harness: AgentHarness<AppContext>,
  permissions: PermissionService,
  auth: RequestAuth
) {
  return harness.hooks.on('before_tool', async ({ toolName, args }) => {
    if (!(await permissions.canCall(auth, toolName, args))) {
      // block 会生成错误 ToolResult,但不会执行底层工具。
      return { block: { reason: '当前用户没有执行该工具的权限' } };
    }
    return undefined;
  });
}

LangChain 用 bind_tools() 把工具交给模型。PI 没有隐藏这个边界,而是把 schema、执行、上下文和权限清楚地放在一个 AgentHarnessTool 上。代码多了一点,排错也直接得多。

接入 MCP 工具

PI Core 不负责连接 MCP Server。应用从 MCP 获取工具后,转换成 AgentHarnessTool:

text 复制代码
McpClient.listTools()
  -> McpTool[]
  -> createMcpTool() 转换 name、description、inputSchema、execute
  -> AgentHarnessTool[]
  -> AgentHarness.create({ tools, activeToolNames })

所以 MCP 适配的核心不是让 PI 学会 MCP,而是做一次协议转换:把远端的工具描述和调用结果,转成 PI 能识别、能执行的工具类型。inputSchema 变成 PI Tool 的 parameters,远端 callTool() 的结果则通过 toPiContent() 变成 AgentToolResult。

ts 复制代码
import type {
  AgentHarnessTool,
  AgentToolResult
} from '@earendil-works/pi-agent-core';
import { Type, type TSchema } from '@earendil-works/pi-ai';
import type { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js';
import type {
  CallToolResult,
  ContentBlock,
  Tool as McpTool
} from '@modelcontextprotocol/sdk/types.js';

interface AppContext {
  tenantId: string;
}

// 把 MCP 的 content 转成 PI ToolResult 可以消费的文本或图片块。
// MCP 的结构化元数据不直接给模型,统一放到 details,避免污染模型上下文。
function toPiContent(content: ContentBlock[]): AgentToolResult<unknown>['content'] {
  return content.map((item) => {
    const part = item as {
      type?: string;
      text?: string;
      data?: string;
      mimeType?: string;
    };

    if (part.type === 'text') {
      return { type: 'text', text: part.text ?? '' };
    }

    if (part.type === 'image') {
      return {
        type: 'image',
        data: part.data ?? '',
        mimeType: part.mimeType ?? 'application/octet-stream'
      };
    }

    // 音频、资源链接等暂时没有统一映射,先保留 JSON,方便模型和日志排查。
    return { type: 'text', text: JSON.stringify(part) };
  });
}

export function createMcpTool(
  serverName: string,
  tool: McpTool,
  client: McpClient
): AgentHarnessTool<AppContext> {
  return {
    // 加 Server 前缀,避免多个 MCP Server 出现同名工具。
    name: `${serverName}_${tool.name}`,
    label: tool.name,
    description: tool.description ?? '',
    parameters: Type.Unsafe(tool.inputSchema as unknown as TSchema),
    execute: async (_toolCallId, args, _onUpdate, _toolContext, _invocation, context) => {
      const result: CallToolResult = await client.callTool(
        { name: tool.name, arguments: args as Record<string, unknown> },
        undefined,
        { signal: context.abortSignal }
      );

      if (result.isError === true) {
        throw new Error(`MCP tool ${tool.name} failed`);
      }

      return {
        content: toPiContent(result.content),
        details: {
          server: serverName,
          tool: tool.name,
          structuredContent: result.structuredContent
        }
      };
    }
  };
}

真正的注册发生在 AgentHarness.create():

ts 复制代码
const { tools } = await mcpClient.listTools();
const harnessTools = tools.map((tool) => createMcpTool('support', tool, mcpClient));
const { harness } = await AgentHarness.create<AppContext>(
  {
    session,
    models,
    model,
    tools: harnessTools,
    activeToolNames: harnessTools.map((tool) => tool.name),
    toolContext: { tenantId: 'tenant-a' }
  },
  context
);

MCP Client 持有远端连接和会话,通常由应用级 Manager 管理。带用户 Authorization 的连接不能跨租户复用;替换连接前要关闭旧 Client,NestJS 退出时也要关闭最后一份连接。

加载和使用 Skill

一个 Skill 本质上是目录里的 Markdown,不是动态代码包:

text 复制代码
skills/
└── refund-policy/
    ├── SKILL.md
    └── references/
        └── refund-rules.md

loadSkills() 读取的是这份文件的 frontmatter 和正文,正文进入 skill.content;filePath 用于定位同目录下的引用文件。应用拿到 Skill[] 后,再决定怎样装配:

ts 复制代码
import {
  formatSkillsForSystemPrompt,
  loadSkills,
  type Context,
  type ExecutionEnv,
  type Skill
} from '@earendil-works/pi-agent-core';

export async function loadSupportSkills(executionEnv: ExecutionEnv, context: Context) {
  const { skills, diagnostics } = await loadSkills(executionEnv, ['./skills'], context);
  const skillIndex = formatSkillsForSystemPrompt(skills);
  const skill = skills.find((item) => item.name === 'refund-policy');

  return {
    skills,
    diagnostics,
    skillIndex,
    skill
  };
}

这里的返回值分别用于:skills 传给 Harness 资源并供显式调用,diagnostics 进入启动日志或配置告警,skillIndex 进入 system prompt,skill 则包含正文和文件位置。系统提示词中只放名称、描述和位置这类轻量索引;完整正文由 load_skill Tool 按需返回。

应用显式调用 Skill 时,直接走 Lane:

ts 复制代码
import type { AgentLane, Context } from '@earendil-works/pi-agent-core';

export function invokeRefundSkill(lane: AgentLane, context: Context) {
  return lane.skill('refund-policy', '只处理当前订单', context);
}

如果希望模型自己选择 Skill,就提供 load_skill Tool:

ts 复制代码
import type { AgentHarnessTool, Skill } from '@earendil-works/pi-agent-core';
import { Type } from '@earendil-works/pi-ai';

export function createLoadSkillTool(skills: Skill[]): AgentHarnessTool<AppContext> {
  return {
    name: 'load_skill',
    label: '读取 Skill',
    description: '需要具体操作步骤或项目规范时读取对应 Skill。',
    parameters: Type.Object({
      name: Type.String({ description: '要读取的 Skill 名称' })
    }),
    execute: async (_toolCallId, { name }) => {
      const skill = skills.find((item) => item.name === name);

      if (!skill) {
        throw new Error(`Skill not found: ${name}`);
      }

      return {
        content: [{ type: 'text', text: skill.content }],
        details: { name: skill.name, filePath: skill.filePath }
      };
    }
  };
}

Harness 不会自动把 Skill 正文加入 system prompt,也不会自动提供读取工具。需要把索引和工具显式传进去:

ts 复制代码
import { AgentHarness, type Context, type Session, type Skill } from '@earendil-works/pi-agent-core';
import type { Api, Model, Models } from '@earendil-works/pi-ai';

interface SkillHarnessInput {
  session: Session;
  models: Models;
  model: Model<Api>;
  context: Context;
  skills: Skill[];
  skillIndex: string;
}

export async function createSkillHarness(input: SkillHarnessInput) {
  const loadSkillTool = createLoadSkillTool(input.skills);
  const { harness } = await AgentHarness.create<AppContext>(
    {
      session: input.session,
      models: input.models,
      model: input.model,
      tools: [loadSkillTool],
      resources: { skills: input.skills },
      systemPrompt: ['你是一个技术支持 Agent。', input.skillIndex].join('\n\n')
    },
    input.context
  );

  return harness;
}

这里的 resources.skills 表示 Harness 知道有哪些 Skill,systemPrompt 负责把索引告诉模型,load_skill Tool 负责把正文按需放进对话。三者不是同一个机制,任何一个都不会自动替你做另外两件事。Skill 正文也属于不可信模型输入,目录要限制,加载要保留 diagnostics,脚本不会因为写在 SKILL.md 里就自动获得执行权。

接入 Milvus RAG

PI 没有向量检索套件。Milvus 负责存储和相似度检索,应用还要补 Embedding、分片、租户过滤、文档版本和 rerank。先定义一个检索 Tool:

ts 复制代码
import type { AgentHarnessTool } from '@earendil-works/pi-agent-core';
import { Type } from '@earendil-works/pi-ai';

interface RagHit {
  documentId: string;
  version: string;
  text: string;
  score: number;
}

interface RagSearchInput {
  query: string;
  topK: number;
  tenantId: string;
}

interface RagService {
  search(input: RagSearchInput, signal?: AbortSignal): Promise<RagHit[]>;
}

const ragParams = Type.Object({
  query: Type.String({ description: '需要检索的问题或关键词' }),
  topK: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 }))
});

// 把检索结果压成带来源的文本,方便模型引用,也方便前端展示出处。
function formatHits(hits: RagHit[]): string {
  return hits.map((hit) => `[${hit.documentId}@${hit.version}] ${hit.text}`).join('\n\n');
}

export function createKnowledgeTool(rag: RagService): AgentHarnessTool<AppContext, typeof ragParams> {
  return {
    name: 'search_knowledge',
    label: '检索知识库',
    description: '问题依赖内部文档、产品规范或历史资料时检索相关片段。',
    parameters: ragParams,
    execute: async (_toolCallId, { query, topK }, _onUpdate, toolContext, _invocation, context) => {
      // tenantId 来自 toolContext,不允许模型自己填写。
      const hits = await rag.search(
        { query, topK: topK ?? 5, tenantId: toolContext.tenantId },
        context.abortSignal
      );

      return {
        content: [{ type: 'text', text: formatHits(hits) }],
        details: { query, hits }
      };
    }
  };
}

如果业务固定要求先检索再回答,可以直接把检索结果写进 prompt:

ts 复制代码
import type { Api, Model, Models } from '@earendil-works/pi-ai';

interface RagAnswerInput {
  models: Models;
  model: Model<Api>;
  rag: RagService;
  tenantId: string;
  question: string;
}

export async function answerWithRag(input: RagAnswerInput) {
  const hits = await input.rag.search({
    query: input.question,
    topK: 5,
    tenantId: input.tenantId
  });

  const systemPrompt = [
    '只根据检索结果回答。',
    '回答中的事实要保留来源标记。',
    '检索结果没有覆盖时,明确说明不知道。'
  ].join('\n');
  const messages = [{
    role: 'user' as const,
    content: `问题:${input.question}\n\n检索结果:\n${formatHits(hits)}`,
    timestamp: Date.now()
  }];
  const stream = input.models.streamSimple(input.model, { systemPrompt, messages });
  let answer = '';

  for await (const event of stream) {
    if (event.type === 'text_delta') {
      answer += event.delta;
    }
  }

  return { answer, hits };
}

这个示例把 hits 经 formatHits() 拼进 user prompt,再用 systemPrompt 约束模型只依据检索结果回答。使用上面的 search_knowledge Tool 时,Tool 返回的 content 会自动变成 ToolResultMessage,Agent Loop 会把原始问题和检索结果一起交给模型继续生成,最终回复从 turn_end 事件或 Session 中读取。

租户、权限和文档版本过滤必须在服务端完成。topK 只是候选数量,阈值、去重和 rerank 放在 RAG Service 内。需要每轮强制检索时,也可以在 transform_context 注入片段,但它更难观察,也更容易重复查询;默认从 Tool 开始更稳。

监听生命周期并处理取消与重试

一次 Harness 运行大致经过:

text 复制代码
lane.prompt()
  -> run_start
  -> turn_start
  -> Provider request
  -> AssistantMessage / toolCall
  -> tool_start / tool_update / tool_end
  -> ToolResultMessage
  -> 下一轮或 run_end

harness.hooks 在运行路径上修改数据,适合权限、上下文、请求头、压缩和工具前后处理。harness.events 做旁路观测,适合日志、指标、审计和 UI 推送。Hook 可以改变本次请求,Event 应该只记录已经发生的事实:

ts 复制代码
import type { AgentHarness, Context } from '@earendil-works/pi-agent-core';

interface RequestLifecycle {
  tenantId: string;
  requestId: string;
}

interface MetricsService {
  record(name: string, payload: unknown): Promise<void>;
}

export function registerLifecycle(
  harness: AgentHarness<AppContext>,
  request: RequestLifecycle,
  metrics: MetricsService
) {
  const stopRequestHook = harness.hooks.on('before_request', async () => {
    return {
      streamOptions: {
        headers: {
          'x-tenant-id': request.tenantId,
          'x-request-id': request.requestId
        }
      }
    };
  });

  const stopRunListener = harness.events.on('run_end', async (event, _context: Context) => {
    await metrics.record('agent_run', event);
  });

  return () => {
    stopRequestHook();
    stopRunListener();
  };
}
Hook 生产用途
before_run 校验消息,补充本次运行资源
transform_context 按租户和知识域调整消息或 system prompt
before_request 添加请求头、超时和租户级限流
before_tool 权限校验、参数清洗、幂等准入
after_tool 结果脱敏、补充审计字段
before_compaction 控制摘要策略,避免丢失关键业务信息

取消和等待都在 Lane 上做:

ts 复制代码
import type { AgentLane, Context } from '@earendil-works/pi-agent-core';

export async function cancelOperation(lane: AgentLane, operationId: string, context: Context) {
  const result = await lane.requestAbort(operationId, context);

  if (!result.ok) {
    return { ok: false as const, error: result.error };
  }

  // 只发出取消信号。工具是否真正停止,取决于它有没有继续传递 abortSignal。
  await lane.waitForIdle(context);
  return { ok: true as const, aborted: result.value };
}

重试也分两层。streamOptions 是 Harness 在每次模型请求时转交给 Models.streamSimple() 的请求选项,里面的 maxRetries 处理一次 Provider 请求内部的瞬时失败。retry 是 Harness 的 operation 级重试,会重新驱动一次未完成的 assistant 生成:

ts 复制代码
import {
  AgentHarness,
  type AgentHarnessStreamOptions,
  type Context,
  type Session
} from '@earendil-works/pi-agent-core';
import type { Api, Model, Models, RetryPolicy } from '@earendil-works/pi-ai';

interface ResilientHarnessInput {
  session: Session;
  models: Models;
  model: Model<Api>;
  context: Context;
}

export async function createResilientHarness(input: ResilientHarnessInput) {
  const streamOptions: AgentHarnessStreamOptions = {
    timeoutMs: 120_000,
    // 这是单次模型请求的最大重试次数。
    maxRetries: 2,
    maxRetryDelayMs: 30_000
  };

  const retry: RetryPolicy = {
    enabled: true,
    // 首次调用不算重试,所以这里表示失败后再尝试 1 次。
    maxRetries: 1,
    baseDelayMs: 1_000
  };

  const { harness, open } = await AgentHarness.create<AppContext>(
    {
      session: input.session,
      models: input.models,
      model: input.model,
      streamOptions,
      retry
    },
    input.context
  );

  return { harness, open };
}

export function updateRetryPolicy(harness: AgentHarness<AppContext>, context: Context) {
  return harness.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 2_000 }, context);
}

两层可以同时工作,所以最坏尝试次数要按相乘关系估算。生产环境最好只让一层负责长退避,另一层保持关闭,或者用统一预算限制整个请求的总耗时。

模型请求可以重试,业务副作用不能靠再跑一次碰运气。有副作用的 Tool 至少要满足一种条件才适合自动恢复:

  • 下游支持业务幂等键。
  • 执行前写 intent,执行后能查询真实结果。
  • 恢复必须经过人工确认。

恢复未完成的 Session operation

Agent 的 messages 是内存状态;Harness 的 Session 才是可恢复事实:

text 复制代码
Session
├── entries:message、compaction、branch_summary、custom
├── lanes:从不同节点继续的对话分支
├── tip:当前分支最后一次提交位置
└── operations:run、compaction、navigation 的持久化状态

AgentHarness.create() 返回的 open 是上次进程留下的未结算 operation。恢复逻辑属于应用层,因为只有应用知道某个副作用有没有真正完成:

ts 复制代码
import type { AgentHarness, Context, OpenOperation } from '@earendil-works/pi-agent-core';

interface RecoveryPolicy {
  canResume(operation: OpenOperation): Promise<boolean>;
}

interface ResumeInput {
  harness: AgentHarness<AppContext>;
  open: OpenOperation[];
  context: Context;
  recoveryPolicy: RecoveryPolicy;
}

export async function resumeOpenOperations(input: ResumeInput) {
  const results = [];

  for (const operation of input.open) {
    if (await input.recoveryPolicy.canResume(operation)) {
      const lane = await input.harness.lane(operation.lane, input.context);
      results.push(await lane.resume(input.context));
    }
  }

  return results;
}

进程可能在订单已经创建、结果还没落库时退出,单靠 operation 无法知道外部系统完成了没有。所以 canResume() 不能只判断数据库里还有一条 running 记录,还要查业务幂等键、执行日志或下游真实状态。

0.85.1 自带两个 Repo:

Repo 适用场景 边界
MemorySessionRepo 测试、演示、短期单进程 进程退出即丢失
JsonlSessionRepo CLI、本地工具、单实例 文件持久化,没有跨进程租约

多副本部署必须保证同一个 Session 同时只有一个可写 owner。可以自己实现共享数据库版 SessionRepo,也可以保留本地 Repo,在入口层使用租约和 fencing token。Sticky session 只能提高命中率,不能替代锁。

在 NestJS 中封装 Agent 模块

前面的能力最终要进入真实后端。这里用一个订单客服场景落地:用户在已有会话里继续提问,后端从登录态确定 tenantId,再把回答流式返回给页面。这个场景同时涉及应用级连接、会话级运行状态和一次 HTTP 请求的生命周期,混在一个 Service 里很容易出现串会话、重复执行和连接泄漏。

下面拆成两个边界:src/agent 只负责 PI 生命周期,可以复制到其他 NestJS 项目直接使用;src/chat 负责 Chat Completions 参数、OpenAI 返回格式和 HTTP/SSE。这样以后接内部聊天页、WebSocket 或任务平台时,不需要改 Agent 模块。

text 复制代码
src/agent/
  bailian.models.ts     # 百炼 Provider 和模型目录
  agent.types.ts        # Agent 输入、领域事件和依赖 token
  agent.service.ts      # Session、Harness、Lane 和 PI 事件
  agent.module.ts       # 只提供并导出 AgentService

src/chat/
  chat.service.ts       # 解析 Chat 请求并映射 OpenAI chunk
  chat.controller.ts    # 只处理 HTTP 和 SSE 写入

划分应用资源与请求对象

Models、Provider 注册表、SessionRepo、MCP Manager 和 Milvus Client 适合应用级单例。这样模型目录、凭据解析、连接池和远端会话只初始化一次。

Context、Session、AgentHarness、AgentLane 和 operation 属于请求或会话级。AgentHarness 绑定一个已经打开的 Session,虽然可以服务多个 Lane,但不能跨 Session 复用;

封装可复用的 Agent 模块

Models 和 SessionRepo 是应用级资源,适合放进 Agent 模块并跟随进程复用;Context 和 AgentHarness 必须在每次请求或会话中创建。

ts 复制代码
// src/agent/agent.types.ts
import type { Context, Session } from '@earendil-works/pi-agent-core';

export const PI_MODELS = Symbol('PI_MODELS');
export const SESSION_MANAGER = Symbol('SESSION_MANAGER');

export interface AgentModelRef {
  provider: string;
  id: string;
}

export interface AgentRunInput {
  model: AgentModelRef;
  // 只传本轮新增的 prompt。之前的历史由同一个 Session 恢复,不从 OpenAI messages 反复导入。
  prompt: string;
  systemPrompt?: string;
  tenantId: string;
  sessionId: string;
  context: Context;
}

// 这个事件是 Agent 模块对外的稳定边界,其他协议出口只消费它。
export type AgentStreamEvent =
  | { type: 'text.delta'; delta: string }
  | { type: 'run.completed' };

export interface SessionManager {
  getOrCreate(tenantId: string, sessionId: string, context: Context): Promise<Session>;
}
ts 复制代码
// src/agent/agent.module.ts
import { Module } from '@nestjs/common';
import { JsonlSessionRepo } from '@earendil-works/pi-agent-core';
import { NodeExecutionEnv } from '@earendil-works/pi-agent-core/node';
import { createBailianModels } from './bailian.models';
import { AgentService } from './agent.service';
import { PI_MODELS, SESSION_MANAGER, type SessionManager } from './agent.types';

@Module({
  providers: [
    { provide: PI_MODELS, useFactory: createBailianModels },
    {
      provide: SESSION_MANAGER,
      useFactory: (): SessionManager => {
        const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() });
        const repo = new JsonlSessionRepo({
          fileSystem: executionEnv,
          sessionsRoot: '/data/pi-sessions'
        });

        return {
          async getOrCreate(_tenantId, sessionId, context) {
            // 这里只是单实例示例;生产环境要换成数据库 Repo,
            // 并校验 tenantId、租约和幂等键。
            const items = await repo.list({ cwd: process.cwd() }, context);
            const metadata = items.find((item) => item.id === sessionId);

            // 同一个 sessionId 会打开已有 Session,上一轮的 user、assistant 和 ToolResult
            // 都从这里恢复,这就是 Agent 的会话记忆。
            return metadata
              ? repo.open(metadata, context)
              : repo.create({ cwd: process.cwd(), id: sessionId }, context);
          }
        };
      }
    },
    AgentService
  ],
  exports: [AgentService]
})
export class AgentModule {}
ts 复制代码
// src/agent/agent.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { AgentHarness } from '@earendil-works/pi-agent-core';
import type { Models } from '@earendil-works/pi-ai';
import { Observable } from 'rxjs';
import {
  PI_MODELS,
  SESSION_MANAGER,
  type AgentRunInput,
  type AgentStreamEvent,
  type SessionManager
} from './agent.types';

interface AppContext {
  tenantId: string;
  sessionId: string;
}

@Injectable()
export class AgentService {
  constructor(
    @Inject(PI_MODELS) private readonly models: Models,
    @Inject(SESSION_MANAGER) private readonly sessions: SessionManager
  ) {}

  stream(input: AgentRunInput): Observable<AgentStreamEvent> {
    return new Observable<AgentStreamEvent>((subscriber) => {
      let stopEvents = () => {};
      let harness: AgentHarness<AppContext> | undefined;

      void (async () => {
        try {
          const model = this.models.getModel(input.model.provider, input.model.id);

          if (!model) {
            throw new Error(`模型不存在:${input.model.provider}/${input.model.id}`);
          }

          const session = await this.sessions.getOrCreate(
            input.tenantId,
            input.sessionId,
            input.context
          );
          // Session 负责恢复历史;本次 prompt 只追加一条新的 user 消息,不重复导入旧历史。
          const created = await AgentHarness.create<AppContext>(
            {
              session,
              models: this.models,
              model,
              // Tool:业务工具加入这里。
              // MCP:把 MCP Tool 转成 AgentHarnessTool 后合并进来。
              // RAG:注册 search_knowledge Tool,租户过滤留在 RAG Service 内。
              tools: [],
              // System prompt 不放进 Message,而是按本次运行单独传给 Harness。
              systemPrompt: input.systemPrompt,
              // 业务身份随工具调用传递,运行时取消信号仍留在 Context 中。
              toolContext: { tenantId: input.tenantId, sessionId: input.sessionId }
            },
            input.context
          );
          harness = created.harness;
          const lane = await harness.lane('main', input.context);

          // 生命周期扩展点:权限校验、上下文注入、压缩、审计和遥测都在这里注册 Hook。
          // Skill:把 Skill 索引加入 systemPrompt,正文用 load_skill Tool 按需读取。
          const stopMessageUpdate = harness.events.on('message_update', ({ event }) => {
            if (event.type === 'text_delta') {
              subscriber.next({ type: 'text.delta', delta: event.delta });
            }
          });

          const stopRunStart = harness.events.on('run_start', (event) => {
            // 主动停止的入口:把 { tenantId, sessionId, runId: event.runId, lane }
            // 写入应用级活动运行表。停止接口拿到 runId 后调用 lane.requestAbort(runId, context)。
            // 运行表可以是单例,但每一项只属于一次请求,不能跨租户或 Session 复用。
          });
          const stopRunEnd = harness.events.on('run_end', (event) => {
            // completed、aborted、failed 都表示运行已经结束,在这里删除活动运行记录。
            // 如果连接先断开,也要在 finally 中清理,避免长期持有 Lane 和请求资源。
          });
          stopEvents = () => {
            stopMessageUpdate();
            stopRunStart();
            stopRunEnd();
          };

          const result = await lane.prompt(input.prompt, undefined, input.context);

          if (!result.ok) {
            throw result.error;
          }

          subscriber.next({ type: 'run.completed' });
          subscriber.complete();
        } catch (error) {
          if (!subscriber.closed) {
            subscriber.error(error);
          }
        } finally {
          stopEvents();
          await harness?.close(input.context);
        }
      })();

      return () => stopEvents();
    });
  }
}

AgentModule 可以被另一个 NestJS 项目直接复制,只要替换 bailian.models.ts、SessionManager 和需要的 Tool。它不关心请求来自浏览器、消息队列还是内部 RPC。

在 ChatService 中适配 OpenAI 输出

下面按 OpenAI Chat Completions 的请求和流式返回实现 NestJS 出口。为了兼容客户端协议需要补齐的字段和帧格式,都直接写在代码注释里。

ts 复制代码
// src/chat/chat.service.ts
import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable } from '@nestjs/common';
import type { Context } from '@earendil-works/pi-agent-core';
import { Observable, type Subscription } from 'rxjs';
import { AgentService } from '../agent/agent.service';

interface ChatMessage {
  // assistant 只为接收标准的 OpenAI 历史数组;当前实现不会把请求中的历史导入 PI Session。
  role: 'system' | 'user' | 'assistant';
  content: string;
}

// 以下类型对齐 OpenAI Chat Completions 请求和流式响应,PI 自身没有这些字段。
export interface ChatCompletionRequest {
  model: string;
  messages: ChatMessage[];
}

interface ChatCompletionChunk {
  id: string;
  object: 'chat.completion.chunk';
  created: number;
  model: string;
  choices: Array<{
    index: number;
    delta: { role?: 'assistant'; content?: string };
    finish_reason: 'stop' | null;
  }>;
}

@Injectable()
export class ChatService {
  constructor(private readonly agent: AgentService) {}

  // ChatService 是对外兼容 OpenAI 协议的边界。
  // 请求消息进入 Agent,Agent 事件再补齐为 OpenAI chunk。
  stream(
    request: ChatCompletionRequest,
    tenantId: string,
    sessionId: string,
    context: Context
  ): Observable<string> {
    // 当前采用服务端 Session 记忆,而不是无状态重放消息数组:
    // 1. system 单独提取并作为本次运行的系统提示词;
    // 2. 最后一条 user 作为本轮新增 prompt;
    // 3. 更早的 user 和 assistant 不读取,历史由同一个 PI Session 提供。
    const prompt = [...request.messages].reverse().find((item) => item.role === 'user')?.content;

    if (!prompt) {
      throw new BadRequestException('messages 中没有 user 消息');
    }

    const [provider, ...modelParts] = request.model.split('/');
    const modelId = modelParts.join('/');

    if (!provider || !modelId) {
      throw new BadRequestException('model 必须使用 provider/model 格式');
    }

    // PI 的 system prompt 与消息数组分开传递,因此从 OpenAI messages 中单独提取 system。
    const systemPrompt = request.messages
      .filter((item) => item.role === 'system')
      .map((item) => item.content)
      .join('\n\n');
    // 如果以后要兼容纯无状态调用,需要为每个请求创建临时 Session,把完整 messages 转成
    // PI Message[] 后交给 lane.prompt()。PI 的 assistant 消息还包含 api、provider、model、
    // usage 和 stopReason 等字段,不能只把 { role, content } 直接转换。此模式也不能继续
    // 复用持久 Session,否则请求中的历史会和已有 Session 历史重复。
    const completionId = `chatcmpl-${randomUUID()}`;
    const created = Math.floor(Date.now() / 1000);
    // OpenAI chunk 必须补齐 id、object、created、model 和 choices,delta 只承载本次增量。
    const toChunk = (
      delta: ChatCompletionChunk['choices'][number]['delta'],
      finishReason: 'stop' | null
    ): ChatCompletionChunk => ({
      id: completionId,
      object: 'chat.completion.chunk',
      created,
      model: request.model,
      choices: [{ index: 0, delta, finish_reason: finishReason }]
    });
    // Chat Completions SSE 要求每个 data 帧以两个换行结束。
    const toFrame = (payload: ChatCompletionChunk | { error: object } | '[DONE]') => {
      const data = payload === '[DONE]' ? payload : JSON.stringify(payload);
      return `data: ${data}\n\n`;
    };

    return new Observable<string>((subscriber) => {
      let agentSubscription: Subscription | undefined;
      let completed = false;
      // OpenAI 流通常先返回 assistant role,再持续返回 content delta。
      subscriber.next(toFrame(toChunk({ role: 'assistant' }, null)));

      agentSubscription = this.agent
        .stream({
          model: { provider, id: modelId },
          prompt,
          systemPrompt: systemPrompt || undefined,
          tenantId,
          sessionId,
          context
        })
        .subscribe({
          next(event) {
            if (event.type === 'text.delta') {
              subscriber.next(toFrame(toChunk({ content: event.delta }, null)));
            }
          },
          error(error: unknown) {
            const message = error instanceof Error ? error.message : String(error);
            // 错误继续使用 OpenAI error 对象,并在结束时补 [DONE]。
            subscriber.next(toFrame({ error: { message, type: 'server_error', param: null, code: null } }));
            subscriber.next(toFrame('[DONE]'));
            subscriber.complete();
          },
          complete() {
            completed = true;
            // finish_reason 表示本轮结束,[DONE] 表示整个 SSE 流结束。
            subscriber.next(toFrame(toChunk({}, 'stop')));
            subscriber.next(toFrame('[DONE]'));
            subscriber.complete();
          }
        });

      return () => {
        if (!completed) {
          agentSubscription?.unsubscribe();
        }
      };
    });
  }
}
ts 复制代码
// src/chat/chat.controller.ts
import { BadRequestException, Body, Controller, Headers, Post, Res } from '@nestjs/common';
import { BACKGROUND_CONTEXT, withAbortSignal } from '@earendil-works/pi-agent-core';
import type { Response } from 'express';
import { ChatService, type ChatCompletionRequest } from './chat.service';

@Controller()
export class ChatController {
  constructor(private readonly chat: ChatService) {}

  @Post('v1/chat/completions')
  stream(
    @Body() body: ChatCompletionRequest,
    // 示例让网关传入租户;正式环境应由 AuthGuard 校验身份后再注入,
    // 不能直接信任公网 header。
    @Headers('x-tenant-id') tenantId: string,
    @Headers('x-session-id') sessionId: string,
    @Res() response: Response
  ) {
    if (!tenantId || !sessionId) {
      throw new BadRequestException('缺少 x-tenant-id 或 x-session-id');
    }

    const abortController = new AbortController();
    const context = withAbortSignal(abortController.signal, BACKGROUND_CONTEXT);

    // 这些响应头让兼容 OpenAI 的客户端按 SSE 流持续读取 response.write 的内容。
    response.setHeader('Content-Type', 'text/event-stream');
    response.setHeader('Cache-Control', 'no-cache');
    response.setHeader('Connection', 'keep-alive');
    response.flushHeaders();

    // ChatService 已经生成 data: JSON 或 data: [DONE] 帧,Controller 只负责写入连接。
    const subscription = this.chat.stream(body, tenantId, sessionId, context).subscribe({
      next: (frame) => response.write(frame),
      error: () => response.end(),
      complete: () => response.end()
    });

    response.on('close', () => {
      // 客户端断开时,这里通过 abortSignal 取消当前运行。Provider 和 Tool 必须监听
      // context.abortSignal;否则它们已经发出的下游请求或副作用仍可能继续执行。
      abortController.abort();
      subscription.unsubscribe();
    });
  }
}

调用时,租户和会话通过 header 传入,模型使用百炼 Provider:

bash 复制代码
curl -N http://localhost:3000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -H 'x-tenant-id: tenant-a' \
  -H 'x-session-id: order-123' \
  -d '{"model":"bailian/qwen-plus","messages":[{"role":"user","content":"你好"}]}'

检查 PI 的能力边界

如果把 LangGraph 放在旁边比较,PI 的缺口会更清楚。LangGraph 的重点是工作流图、图状态、checkpoint 和时间回溯;PI 的重点是 Agent Loop、消息、工具和可恢复的 Session。两者都能承载 Agent,但 Harness 能直接调用的能力并不一样。

Harness 能力 LangChain / LangGraph 生态 PI 0.85.1 自己要做的事
工作流编排 LangGraph 可以定义节点、边、条件路由、并行分支和 interrupt 只有 Agent Loop、Lane 和 operation,没有通用图运行时 用业务状态机、队列或工作流引擎包住 Harness
状态 checkpoint 可以读取 graph state 快照,并基于旧 checkpoint 继续执行或 replay Session 可以保存消息、分支和 operation 状态 自己把业务状态、工具结果和外部系统状态接进持久化模型
时间回溯 可以切到历史 graph state,再重新运行后续节点 navigateTree() 能切换对话分支,但不能撤销已经执行的副作用 用补偿事务、反向操作和审计记录处理数据库、支付、邮件等外部副作用
Durable execution 有 checkpointer、持久化执行和平台侧调度等生态能力 operation 能恢复,但没有跨副本租约、fencing 和调度器 自己做 Session coordinator、锁、队列、幂等键和故障转移
Session 存储 有数据库 checkpointer、Store 和托管平台可选 自带内存和 JSONL Repo,适合测试、CLI、单实例 实现数据库 SessionRepo,并区分租户、环境和权限
RAG 与长期记忆 向量库、Retriever、Memory 和工具生态选择很多 没有检索套件,也没有长期记忆实现 接 Embedding、Milvus、rerank、文档权限和记忆淘汰策略
MCP 与工具生态 有较多现成工具包、MCP adapter 和 tracing 集成 Core 不认识 MCP,Tool 也要按 PI 类型自己注册 把 MCP Tool 转成 AgentHarnessTool,并管理连接、OAuth 和租户隔离
可观测性与评测 有 LangSmith 等链路、数据集和评测工具 Hook 和 Event 只提供运行时切面 补指标、trace、告警、回放、数据集和离线评测

这里最值得注意的不是 "PI 功能少",而是它把平台职责留给了应用。LangGraph 的 checkpoint 能恢复图状态,也不代表它会自动回滚一次已经完成的退款;PI 的 navigateTree() 能回到过去的对话分支,同样只能重放上下文,不能替业务撤销副作用。真正的时间回溯在生产里永远是 "状态恢复 + 副作用补偿 + 幂等" 三件事,不会因为换一个 Agent 框架就消失。

MCP 也是同理。PI 没有把 MCP 当成内建协议,而是在应用层做一次适配:

text 复制代码
MCP Tool
  -> inputSchema / callTool result
  -> AgentHarnessTool.parameters / execute
  -> Harness 注册

这说明 PI 的扩展方式通常很直接:提供协议转换和工具注册,而不是再包一层隐式插件系统。代价是代码量更多,收益是生命周期、权限和错误边界都掌握在自己手里。

Skill 也同样轻。loadSkills() 读 Markdown,system prompt 只拿索引,正文由应用自己的 load_skill Tool 按需加载。它适合做可版本化的指令包,不负责依赖安装、进程隔离或权限授予。

所以,PI 更像一套可组合的 Harness 内核:模型、消息、Loop、Tool、Session 和生命周期都有清楚的接口;工作流、持久化、RAG、长期记忆、评测和完整观测要按自己的生产要求补齐。选择 PI 的收益是控制权和可读性,成本就是这些平台能力不会凭空出现。

相关推荐
To_OC12 小时前
从一头雾水到跑通全流程:我用一个周末啃透了JWT登录鉴权
前端·后端·http
前端兰博13 小时前
05-Redis
redis·后端
想要打 Acm 的小周同学呀13 小时前
无需自己设计Agent架构的业务系统,依赖第三方Agent,基于SKILL和MCP服务实现企业级内部提效工具开发
架构·agent
databook14 小时前
从手动检查到自动监控:一个数据质量工作流的实现
后端·python·数据分析
【JAVA】玩家14 小时前
Spring核心原理全解析:从零到生产实战
java·后端·spring
东风破_14 小时前
从 Neo4j 到 GraphRAG:用 Text-to-Cypher 构建图检索 RAG
人工智能·后端
老周聊架构16 小时前
智能体业务关键引擎:Agent Skills 与企业能力资产沉淀
agent·skills
hpoenixf16 小时前
一个 Bug 为什么要改五层:Agent 架构如何从跨层修补走向局部 Owner
agent
llqbzllll16 小时前
Spring AI 工具调用不是反射一下就结束:用 2.0.1 跑通失败恢复与调用上限
人工智能·后端
网络毒刘17 小时前
用测试夹具约束 Agent:给定失败用例,要求只输出最小 diff 的提示词套路
agent·测试·提示词·diff