跳到主内容
创建时间:最后更新:作者:白川

让工具返回可复用的结构化数据

区分给模型的文本与给程序的数据两条通道,理解 structuredContent 不进模型上下文,在消费方显式验证后再消费。

让工具返回可复用的结构化数据

模型提交给工具的参数是一种结构化输入;工具执行完成后返回给其他程序的数据,则是结构化结果。两者流向不同,不应该共用一个含糊的"输出 JSON"概念。

本篇用确定性的教学样本演示工具结果契约,不调用模型、不分析任意代码。沿用Schema 校验中的 schema.ts 和 lesson.ts,可以离线运行。

两条通道

AgentToolResult 是工具执行后的返回值。它的定义在 packages/agent/src/types.ts:

typescript
export interface AgentToolResult<T = JsonValue | undefined> {
	/** Text or image content returned to the model. */
	content: (TextContent | ImageContent)[];
	/** Arbitrary structured details for logs or UI rendering. */
	details: T;
	/**
	 * Machine-readable result matching the tool's `outputSchema`, for programmatic callers. Not sent
	 * to the model; `content` remains the model-facing result.
	 */
	structuredContent?: JsonValue;
	usage?: Usage;
	/**
	 * Report a failure without throwing. The model sees `content` as an error result, like a thrown
	 * error, but `details` and `structuredContent` are kept for the UI and programmatic callers.
	 */
	isError?: boolean;
	terminate?: boolean;
}

structuredContent 的注释里那句 "Not sent to the model" 是本篇的核心事实。翻译过来:给模型的和给程序的是两条通道。

字段面向谁是否进入模型上下文典型用途
content
模型
是
给模型看的简短说明
details
界面、日志、状态记录
否
任意结构,UI 渲染用
structuredContent
程序消费方
否
完整的机器可读结果
outputSchema
结果契约的声明
—
structuredContent 应符合的 JSON Schema

outputSchema 定义在 AgentTool 上:

typescript
export interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
	label: string;
	prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
	/**
	 * JSON Schema of `structuredContent` in successful results. Tools that declare it should always
	 * set `structuredContent`.
	 */
	outputSchema?: object;
	execute: (
		toolCallId: string,
		params: AgentToolParams<TParameters>,
		signal?: AbortSignal,
		onUpdate?: AgentToolUpdateCallback<TDetails>,
	) => Promise<AgentToolResult<TDetails>>;
	replay?: "never" | "safe";
	executionMode?: ToolExecutionMode;
}

注释说得很清楚:声明了 outputSchema 的工具就应该总是设置 structuredContent。声明是承诺,不是自动检查——框架不会替你验证 structuredContent 是否真的符合 outputSchema。

验证:structuredContent 不进消息

这一点值得实际验证,而不是靠读文档相信。packages/agent/src/agent-loop.ts 里构造工具结果消息的代码:

typescript
function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {
	return {
		role: "toolResult",
		toolCallId: finalized.toolCall.id,
		toolName: finalized.toolCall.name,
		// Untyped tools (JS extensions) can return results without content; normalize
		// so the null never enters session history or provider payloads.
		content: finalized.result.content ?? [],
		details: finalized.result.details,
		usage: finalized.result.usage,
		isError: finalized.isError,
		...(finalized.durationMs === undefined ? {} : { durationMs: finalized.durationMs }),
		timestamp: Date.now(),
	};
}

构造出的 ToolResultMessage 只有 content、details、usage、isError——没有 structuredContent。它停留在 AgentToolResult 这一层,通过 tool_execution_end 事件暴露给订阅者,不进入转录。

对应地,ToolResultMessage 的类型定义里也根本没有 structuredContent 字段:

typescript
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true
	? {
			role: "toolResult";
			toolCallId: string;
			toolName: string;
			content: (TextContent | ImageContent)[];
			details?: JsonRepresentation<TDetails>;
			usage?: Usage;
			nestedCalls?: NestedToolCalls;
			isError: boolean;
			timestamp: number;
			durationMs?: number;
		}
	: never;

可以写一段代码直接观察到这一点:

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

const agent = new Agent({
	initialState: {
		systemPrompt: "s",
		model,
		tools: [{
			name: "stats",
			label: "Stats",
			description: "统计数据",
			parameters: { type: "object", properties: {}, additionalProperties: false },
			outputSchema: {
				type: "object",
				properties: { count: { type: "number" } },
				required: ["count"],
				additionalProperties: false,
			},
			async execute() {
				return {
					content: [{ type: "text", text: "共 5 条记录" }],
					details: { rows: 5 },
					structuredContent: { count: 5 },
				};
			},
		}],
	},
	streamFn: models.streamSimple.bind(models),
	getApiKey: () => "k",
});

agent.subscribe((event) => {
	if (event.type === "tool_execution_end") {
		// 事件里能看到 structuredContent
		console.log("事件中的 structuredContent:", event.result.structuredContent);
	}
});

await agent.prompt("统计");

for (const message of agent.state.messages) {
	if (message.role === "toolResult") {
		// 消息里没有
		console.log("消息中的 structuredContent:", (message as any).structuredContent); // undefined
		console.log("消息中的 content:", JSON.stringify(message.content));
	}
}

输出:

text
事件中的 structuredContent: { count: 5 }
消息中的 structuredContent: undefined
消息中的 content: [{"type":"text","text":"共 5 条记录"}]

同一份数据,在事件里有,在消息里没有。 这就是两条通道的差别。

编写带结构化结果的工具

创建 src/structured-tool.ts:

typescript
import type { AgentTool } from "@earendil-works/pi-agent-core"
import { obj } from "@earendil-works/pi-schema"
import { analysisSchema, validateAnalysis } from "./schema.js"

const emptyParameters = obj({})

export const inspectLesson: AgentTool<typeof emptyParameters> = {
  name: "inspect_lesson",
  label: "取得练习分析",
  description: "返回教学样本的分析数据,用于演示工具输出契约。",
  parameters: emptyParameters,
  outputSchema: analysisSchema,
  async execute() {
    // 这是确定性的教学样本,不是声称模型已分析或执行了任意代码。
    const value: unknown = {
      name: "first",
      parameters: ["items"],
      summary: "返回数组的第一个元素",
      emptyInputBehavior: "返回 undefined",
      evidence: "return items[0]",
    }
    const analysis = validateAnalysis(value)
    return {
      content: [{ type: "text", text: `函数 ${analysis.name}:${analysis.summary}` }],
      details: { origin: "lesson-fixture" },
      structuredContent: analysis,
    }
  },
}

这里的对象由程序构造,明确标注为教学样本。它用来演示输出分发,不冒充一次已经发生的模型分析。

validateAnalysis() 在返回前验证数据。outputSchema 声明消费者可以期待的结构,但不能据此省略运行时检查。当前示例版本中的 Agent Core 并未替这个工具的所有返回值自动执行同一份应用校验。

注意 content 里只放了一句摘要。如果模型下一步需要某项具体证据,就要在 content 里提供它——structuredContent 里的完整对象模型看不到。

在消费方再次守住边界

创建 src/consume-tool.ts:

typescript
import { validateAnalysis } from "./schema.js"
import { inspectLesson } from "./structured-tool.js"

// 直接调用执行器便于观察结果结构,不代表经过了完整 Agent 参数校验链路。
const result = await inspectLesson.execute("lesson-1", {})
if (result.isError) {
  throw new Error("工具报告失败,不能把附带数据当作正常结果")
}
const analysis = validateAnalysis(result.structuredContent)
console.log("给模型的摘要:", result.content)
console.log("给程序的数据:", analysis)
console.log("界面与日志信息:", result.details)

运行:

bash
node --import tsx src/consume-tool.ts

检查输出:模型摘要不包含完整数据对象,程序拿到带字段的分析结果,界面信息单独保留。这比在下一步重新解析一段自然语言摘要更清楚。

直接调用 execute() 只用于观察结果,不意味着已经经过完整运行时的参数验证、事件与权限流程。本例输入是固定空对象;把它接入实际工具系统时,还需要保持原来的执行链路。

消费方检查 isError 后才验证数据。类型或 Schema 声明来自别人时,不应盲目信任;从外部工具、跨进程消息或历史记录恢复的数据,都可以在消费入口重新检查。

在宿主里读到它

在真实运行时,消费方通常通过订阅事件拿到结构化结果,而不是直接调 execute():

typescript
agent.subscribe((event) => {
  if (event.type !== "tool_execution_end") return;
  const { toolName, result, isError } = event;
  if (isError) {
    reportFailure(toolName, result.content);
    return;
  }
  if (result.structuredContent !== undefined) {
    const parsed = validateAnalysis(result.structuredContent);
    saveToDatabase(parsed);
  }
});

tool_execution_end 事件的类型:

typescript
type ToolExecutionEndEvent = {
	type: "tool_execution_end";
	toolCallId: string;
	toolName: string;
	result: any;
	isError: boolean;
	durationMs?: number;
};

result 是完整的 AgentToolResult,所以 structuredContent 和 details 都在。事件的 isError 是最终判定——注意它可能与 result.isError 不同,因为 afterToolCall 钩子可以改写它。

失败结果也可能携带数据

某些失败需要保留诊断信息,例如执行失败但已经获得部分统计。工具可以按契约返回 isError: true,同时携带 details 或 structuredContent。消费者应先处理失败状态,再决定是否读取附带数据,不把字段存在当成成功。

AgentToolResult.isError 的注释说明了这一点:

Report a failure without throwing. The model sees content as an error result, like a thrown error, but details and structuredContent are kept for the UI and programmatic callers.

翻译:不抛异常也能报告失败。模型看到的 content 与抛异常时一样是错误结果,但 details 和 structuredContent 仍然留给界面和程序消费方。

这和 Agent Core 的另一条约定一致——AgentTool.execute() 的注释写着:

Execute the tool call. Throw on failure, or return a result with isError: true; do not only describe the failure in content.

不要只在 content 里描述失败。抛异常或设 isError: true,两者选一。

错误数据结构通常与成功结构不同,应单独设计或显式声明联合状态。本例只演示成功结构,不能在失败时强塞一个字段齐全的成功对象。

afterToolCall 会改写结构化结果

afterToolCall 钩子可以覆盖工具结果。它的合并语义是逐字段的:

typescript
export interface AfterToolCallResult {
	content?: (TextContent | ImageContent)[];
	details?: unknown;
	structuredContent?: JsonValue;
	isError?: boolean;
	usage?: Usage;
	terminate?: boolean;
}

有一条容易忽略的规则,源码注释写得很清楚:

structuredContent: if provided, replaces the structured content. If content is provided without it, the structured content is dropped, because it may no longer match the content. Return it along with content to keep it.

翻译:如果只替换了 content 而没同时给 structuredContent,结构化结果会被丢弃——因为它可能已经和新的内容对不上了。要保留就得两个一起返回。

实现代码:

typescript
const structuredContent =
	afterResult.structuredContent ?? (afterResult.content ? undefined : result.structuredContent);
result = {
	...result,
	content: afterResult.content ?? result.content,
	details: afterResult.details ?? result.details,
	usage: afterResult.usage ?? result.usage,
	terminate: afterResult.terminate ?? result.terminate,
};
if (structuredContent === undefined) delete result.structuredContent;
else result.structuredContent = structuredContent;

用坏数据测试契约

把样本的 summary 临时改成数字,返回前校验应失败。即使一个执行器漏掉校验,消费方仍应拒绝不符合结构的数据。模拟检查覆盖了"工具声明了 Schema,但实际返回错误结构"的情况,避免把文档声明误当成执行保证。

到这里,工具输入和工具输出都有了明确边界。最后一篇处理流式、不完整结果与纠正重试。

本篇的"两条通道"行为用仓库源码实测过:structuredContent 出现在 tool_execution_end 事件里,不出现在 toolResult 消息里。本轮未与真实模型联调。

参考资料