把 Agent 框架拆开:PI 开发生产级 Harness

PI 侧主要使用两个包:

  • @earendil-works/pi-ai 负责模型、认证、协议 adapter 和消息类型。
  • @earendil-works/pi-agent-core 负责 Agent Loop、AgentAgentHarness、Session、Tool、Skill 和运行生命周期。

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

其实我想表达的是,Agent 开发可能已经到了标准化的流程。无论最终对用户是什么形式,核心都离不开不同厂家模型适配、智能体、工具、MCP 和 Skill,后面再是基于目标用户又一层包装。

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

连接模型

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

Model、Provider 和 Models 的分工

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

Provider.baseUrl 不会自动覆盖或拼接到 Model.baseUrl。0.85.1 真正发请求时读取的是 Model.baseUrl,所以自定义服务必须为每个 Model 设置正确地址。

getModel(providerId, modelId) 是获取模型的主要方法,前提是这个模型必须先注册到对应的 Provider

连接内置 Provider

0.85.1 内置了 OpenAI、Anthropic、Google、DeepSeek、Groq、Mistral、xAI、OpenRouter 等常见 Provider。

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

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

openaiProvider()anthropicProvider() 都已经内置各自的 baseUrl、认证环境和协议 adapter,所以创建时不需要传 URL。 前者使用 OpenAI Responses adapter,后者使用 Anthropic Messages adapter。

getModel() 取出的 Model 要连同本次请求的 Context 一起交给 Models.streamSimple()

自定义 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('模型不存在或未配置');
}

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) 声明 API Key 从哪里取,不会在创建 Provider 时立即读取或保存 Key。

  • name 用于认证状态展示
  • envVars 每次请求前,Models 会先查已保存的 credential,再回退到 DASHSCOPE_API_KEY

Model 中需要重点关注的字段如下:

字段 作用
api 决定使用哪套协议 adapter,本例是 openai-completions
provider 必须和 Provider.id 一致,否则 Models 找不到归属
baseUrl 本次请求实际使用的 API 根路径
contextWindowmaxTokens 参与上下文预算、压缩和输出限制
compat 覆盖 OpenAI 兼容服务的字段差异,避免把百炼不支持的字段直接发过去

openAICompletionsApi() 是一个双向适配器:发送前把 PI 请求转成 OpenAI Chat Completions 格式,接收后把响应流转回 PI 的 AssistantMessageEvent。 它只负责 PI 访问上游模型,不会让 PI 自动对外返回 OpenAI 格式;如果服务端还要兼容 OpenAI,需要另外实现出口适配层。

一次模型请求如何变成 Agent Loop

先记住一句话:Agent Loop 会反复执行"请求模型 -> 判断是否调用工具 -> 执行并回填结果 -> 再请求模型",直到模型不再调用工具或运行时要求停止。

text 复制代码
用户消息
  -> 请求模型
  -> 有 toolCall?
       -> 执行工具
       -> 写入 ToolResultMessage
       -> 再请求模型
  -> 没有 toolCall
       -> 结束本轮 turn 并返回回答

Agent Loop 分成内外两层。

  • 内层负责把当前任务真正推进下去:请求模型,如果模型要求调用工具就执行工具;如果此时队列里有 steering 消息,就把它插到上下文里,再继续请求模型。内层会一直重复,直到既没有工具调用,也没有 steering 消息。

  • 外层不参与模型和工具的往返,它只在内层准备停下时检查 follow-up;如果发现 follow-up,就把它当作新的输入,再启动一轮内层循环,否则结束整个任务。

  • steering:Agent 正在工作时"改方向"。它不会中断当前正在执行的工具,等当前 turn 的工具全部结束后,才插入上下文,影响下一次模型请求。

  • follow-up:Agent 本来准备结束时"追加一件事"。只有内层已经没有工具调用和 steering,外层才会检查它,有消息就再跑一个 turn。

内层负责模型、工具和 steering 的往复,外层负责在准备结束时接上下一个 follow-up。提前结束有四种常见原因:

  • error:模型请求或运行过程发生错误。
  • aborted:调用方主动取消当前运行。
  • 预算收口:应用通过 shouldStopAfterTurn() 判断轮次、耗时、费用或上下文空间已经超限。
  • 工具终止:只有当前整批工具结果都设置 terminate: true,才跳过自动请求模型的下一步。

PI 没有为每个 Agent 创建 LangGraph 那样的状态图,所谓 loop 本质上一直围绕同一组 AgentMessage[] 推进。生产代码通常不直接调用 agentLoop(),而是交给 AgentHarness,由它补充持久化、工具、Hook、取消和恢复。

Agent 与 AgentHarness 的关系

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

Agentnew 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: []
  }
});

AgentHarness 是带 create() 方法的工厂,创建后得到的是绑定某个 Session 的 harness。两者的关系是: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,因为它额外提供了持久化身份、配置快照、Hook 与 Event,以及同一 Lane 的并发操作保护。最小骨架如下:

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。这也意味着 AgentHarness 不能跨 Session 复用,后面的 NestJS 封装会按请求创建它。

消息与 system prompt 怎么组装

PI 内部保存 AgentMessage[],Provider 真正接收的是 Message[]。两者不能混用:前者包含 Session、UI、恢复和运行过程需要的信息,后者只能包含模型协议允许的内容。请求模型前,Harness 通过 toProviderMessages 完成转换,convertToLlm 是 PI 提供的默认实现。

text 复制代码
Session 中的 AgentMessage[]
  -> toProviderMessages()
  -> convertToLlm() 默认转换
  -> Provider 能接收的 Message[]

默认转换后,Provider 侧只有三种消息:

类型 关键字段 作用
UserMessage role: "user"contenttimestamp 用户文本或图片
AssistantMessage contentprovidermodelusagestopReason 模型文本、thinking 和 tool call
ToolResultMessage toolCallIdtoolNamecontentisError 把工具结果关联回调用

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

PI 没有 SystemMessage,system prompt 和消息数组分开传递。下面把系统提示词和消息转换放在同一个 Harness 配置里:

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

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

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

转换的原因很直接:AgentMessage 可能包含压缩摘要、branch summary、CustomMessage 和运行字段,Provider 不认识这些结构。convertToLlm() 会保留 userassistanttoolResult,并把内置摘要转成模型可读的 user 内容。项目自己的消息类型如果不需要给模型看,就在转换时过滤;如果需要,则要实现对应规则。直接做类型断言只能骗过 TypeScript,请求仍会被 Provider 拒绝。

发送消息统一走 Lane。lane.prompt() 返回 RunResult,不是直接返回 AssistantMessageresult.value 是 operation 的记录,消息正文要从 Session 或事件流读取:

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 };
  }

  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;
  });
}

把 MCP 工具适配成 PI Tool

PI Core 不负责连接 MCP Server,应用需要把 MCP 工具转换成 AgentHarnessTool

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

适配的重点是协议转换:inputSchema 变成 PI Tool 的 parameterstoPiContent() 只负责把 MCP content 转成 PI 的内容块,createMcpTool() 再把返回值和 details 组装成 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 { ContentBlock, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';

interface AppContext {
  tenantId: string;
}

// 把 MCP content 转成 PI ToolResult 可以消费的文本或图片块。
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 = 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 会进入下一轮模型上下文,details 只供应用侧记录和展示。
        content: toPiContent(result.content),
        details: {
          server: serverName,
          tool: tool.name,
          structuredContent: result.structuredContent
        }
      };
    }
  };
}

createMcpTool() 的返回值可以直接和普通业务 Tool 合并到 tools,再通过 activeToolNames 控制当前 turn 模型能看到哪些工具,绑定方式和上一节一致。

加载和使用 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 负责把正文按需放进对话。

接入 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 };
}

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

一次 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,每条 operation 记录 lane、operationId、kind 等状态。RecoveryPolicy 不是 PI 内置接口,而是应用自己定义的恢复准入策略,因为只有应用知道某个副作用有没有真正完成:

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

interface RecoveryPolicy {
  // true 表示业务确认可以安全继续;false 表示保留现场,等待补偿或人工处理。
  canResume(operation: OpenOperation): Promise<boolean>;
}

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

export async function resumeOpenOperations(input: ResumeInput) {
  const results = []; // 记录成功恢复的 operation 结果

  // open 是 AgentHarness.create() 从 Session 中读出的遗留 operation,不是本次用户请求。
  for (const operation of input.open) {
    // PI 只知道 operation 尚未结束,不知道订单、支付等外部副作用是否已经发生。
    // 所以先让应用根据幂等键、执行日志或下游状态判断能否继续。
    if (await input.recoveryPolicy.canResume(operation)) {
      // 确认安全后,从该 operation 的持久化状态继续驱动对应 Lane。
      const lane = await input.harness.lane(operation.lane, input.context);
      results.push(await lane.resume(input.context));
    }
  }

  return results;
}

例如进程可能在订单已经创建、结果还没写回 Session 时退出。此时如果直接 lane.resume(),工具可能再次创建订单;canResume() 应该先确认幂等键、执行日志或下游订单状态。返回 false 时不要恢复,可以把这条 operation 保留下来,交给补偿任务、对账流程或人工处理。

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 写入

封装可复用的 Agent 模块

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

ChatService 从请求中提取 systemPrompt 和最后一条 user 消息,后者作为 userPrompt 交给 AgentService。AgentService 恢复 Session 后只把这条新消息交给 lane.prompt(),历史不再重复导入。

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;
  // 本轮新增的用户输入。之前的历史由同一个 Session 恢复,不从 OpenAI messages 反复导入。
  userPrompt: 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 storageId = `${tenantId}:${sessionId}`;
            const items = await repo.list({ cwd: process.cwd() }, context);
            const metadata = items.find((item) => item.id === storageId);

            // 同一个 sessionId 会打开已有 Session,上一轮的 user、assistant 和 ToolResult
            // 都从这里恢复,这就是 Agent 的会话记忆。
            return metadata
              ? repo.open(metadata, context)
              : repo.create({ cwd: process.cwd(), id: storageId }, 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 负责恢复历史,本次 userPrompt 只追加一条新消息,
          // 不重复导入旧历史。
          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.userPrompt, 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();
    });
  }
}

在 ChatService 中适配 OpenAI 输出

下面按 OpenAI Chat Completions 的请求和流式返回实现 NestJS 出口。当前版本只覆盖简单文本流,还不是完整的 OpenAI adapter,

如果要长期维护 OpenAI 兼容能力,应把请求转换、事件映射、错误和结束帧抽到独立 adapter

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 作为本轮新增 userPrompt;
    // 3. 更早的 user 和 assistant 不读取,历史由同一个 PI Session 提供。
    const userPrompt = [...request.messages].reverse().find((item) => item.role === 'user')?.content;

    if (!userPrompt) {
      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 AgentMessage[] 后再交给 lane.prompt()。assistant 消息必须补齐 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 },
          userPrompt,
          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,放弃了什么,又要自己实现什么

选择 PI 之后,最先感受到的不是某个能力缺失,而是很多默认决策回到了应用侧。模型、消息、Agent Loop、Tool、Session 和生命周期都有清晰入口,但怎样组合它们并没有一份跨业务通用的答案。PI 提供的是原子化操作,应用决定怎样把这些变成自己的产品。

以 RAG 为例,PI 没有检索器、向量库封装或 reranker,检索链路要自己接。这看起来像是在补齐缺失的套件,实际上也暴露了业务边界:文档怎样切分、按哪个版本召回、租户权限在哪里过滤、结果是否需要重排,本来就不适合由 Agent runtime 统一替业务决定。

会话和协议也是同样的取舍。Session 和 Lane 提供了恢复与分支运行的基础结构,但多副本下的持久化、写租约和租户隔离仍然要自己实现;JsonlSessionRepo 可以支撑本地工具或单实例服务,却不能直接等同于生产数据库。对外兼容 OpenAI 也依赖单独的 adapter,openAIResponsesApi() 只处理上游协议,应用还要负责下游请求、事件、错误和结束帧的转换。

所以 PI 的边界很明确:它不替你搭建平台,而是尽量减少运行时对你的隐藏。它适合已经理解 Agent 运行方式、愿意用更多基础设施代码交换控制权和可替换性的团队。需要快速获得完整平台能力的项目,通常会从更重的框架或托管运行时开始;希望把模型、会话、工具、循环和协议边界掌握在自己手里的项目,PI 会更合适。

相关推荐
步行cgn1 小时前
Spring Bean 的生命周期详解
java·后端·spring
Epat1 小时前
关于我是如何将 DeepSeek Harness 改造成一支AI团队
agent·ai编程·deepseek
爱勇宝2 小时前
用了一个月 WorkBuddy,聊聊我的真实感受
前端·后端·程序员
行百里er2 小时前
Spring Insight 里如何把 Span 画成瀑布时间线
spring boot·后端·监控
卷无止境2 小时前
从终端里长出来的 IDE,oh-my-pi 到底是个什么东西
人工智能·后端
殷紫川2 小时前
MemPalace :本地全量存储的 AI 记忆系统,96.6% 长记忆召回率的实现逻辑
agent
万物智能2 小时前
PWM散热风扇设置—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端·算法
Darling噜啦啦2 小时前
别再用「死板 RAG」!手把手实现会思考、会纠错、会联网的 Agentic RAG(LangGraph 实战)
langchain·agent