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、协议、认证、模型目录 ModelsProviderModel
组织上下文 消息、system prompt、工具 schema MessagesystemPromptAgentHarnessTool
驱动执行 模型与工具之间的循环 agentLoop(),生产侧由 AgentHarness 承接
扩展运行 middleware、hook、event harness.hooksharness.events
保存状态 session、checkpoint、恢复 SessionRepoSessionAgentLane

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

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

PI 仓库里还有 CLI、终端交互和运行器编排等能力。它们主要服务本地命令行产品。更常见的链路是浏览器发起请求,NestJS 持有 Provider API Key,再通过 PI 调模型。本文只引入 pi-aipi-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/v1OPENAI_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/v1openAICompletionsApi() 会在后面拼接 /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 根路径。
  • contextWindowmaxTokens 参与上下文预算、压缩和输出限制。
  • 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() ModelContext、完整 StreamOptions AssistantMessageEventStream 使用完整协议参数发起请求
streamSimple() ModelContextSimpleStreamOptions AssistantMessageEventStream 使用简化参数发起请求
fetchDeferred() ModelDeferredHandle AssistantMessageEventStream 查询远端异步任务,可选能力
cancelDeferred() ModelDeferredHandle Promise<void> 取消远端异步任务,可选能力

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

集成 Agent

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

理解 Agent Loop 的执行过程

LangChain 的 create_agent 和 PI 的 AgentAgentHarness 在使用层面很像:都接收模型、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() 有一个很关键的约束:上下文最后一条消息必须能转换成 usertoolResult,否则下一轮模型请求会不合法。

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

AgentAgentHarness 都从 @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"contenttimestamp 用户文本或图片
AssistantMessage contentprovidermodelusagestopReason 模型文本、thinking 和 tool call
ToolResultMessage toolCallIdtoolNamecontentisError 把工具结果关联回调用

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

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

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() 会保留 userassistanttoolResult,并把 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 等恢复信息

toolstoolContext 都是 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.contentfilePath 用于定位同目录下的引用文件。应用拿到 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 };
}

这个示例把 hitsformatHits() 拼进 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

Agentmessages 是内存状态;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 适合应用级单例。这样模型目录、凭据解析、连接池和远端会话只初始化一次。

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

封装可复用的 Agent 模块

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

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.tsSessionManager 和需要的 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 的收益是控制权和可读性,成本就是这些平台能力不会凭空出现。

相关推荐
贾伟康1 小时前
【HarmonyOS 7新能力|026】Agent Framework Kit工程封装:把接入逻辑放进可维护的分层结构
agent·harmonyos·arkts·a2a·harmonyos 7
用户8356290780511 小时前
如何使用 Python 给 Word 文档添加水印
后端·python
深蓝电商API2 小时前
MCP 与 AI Agent 如何改变爬虫开发模式?
爬虫·agent·mcp
szephyr2 小时前
WebSocket 实战:心跳、断线重连、鉴权,一次讲清
前端·websocket·node.js·长连接·实时通信
一个风轻云淡2 小时前
Markdown学习与实践
后端
程序员cxuan2 小时前
真没想到,AI 圈又杀出来一匹黑马!
人工智能·后端·程序员
秋秋小事3 小时前
node postgreSQL的select与include
node.js
掘金者阿豪3 小时前
极空间 NAS 部署 Typecho:从 Docker 安装、主题配置到固定公网访问
后端
科技苑3 小时前
前后端分离与微服务架构如何协同?
前端·后端·前端框架