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