Headless 工具
通过 Headless 工具实现在客户端运行浏览器和设备 API
Headless 工具允许你的 Agent 调用其实际执行必须在用户应用中而非服务器上发生的工具。Agent 看到的仍然是普通的工具模式(schema),但其实现在前端中运行,可以在前端访问浏览器 API,例如 IndexedDB、地理位置(geolocation)、剪贴板(clipboard)、Canvas 或文件选择器(file pickers)。
当数据应当保留在设备本地时,这种模式尤其有用。本页上的 Playground 示例使用了一个由 IndexedDB 支持的小型浏览器内存工具包,以及一个完全在客户端运行的地理位置工具。
Headless 工具的工作原理
从高层架构来看,Headless 工具将工具模式(schema)与纯浏览器端实现分离开来。
- 在 Agent 上注册仅包含模式(schema-only)的工具定义。
- 在前端使用
.implement(...)实现相匹配的工具。 - 将这些实现传递给
useStream({ tools: [...] })。 - 当 Agent 发出匹配的工具调用时,客户端会执行它,并使用工具结果恢复被中断的运行。
TIP
将工具定义与实现分别存放在独立的模块中。在 Agent 和前端之间共享这些定义,以确保工具名称和模式保持一致,然后将纯浏览器端代码保留在仅限客户端的 impl 模块中。
在 Agent 上注册工具
Playground 定义了一组遵循相同模式的客户端工具:Agent 公开工具模式(schema),而前端处理实际执行。
在共享的 tools.ts 文件中定义一次工具,并在 Agent 和前端两处都使用该文件。
import * as z from "zod";
import { tool } from "langchain";
export const memoryPut = tool({
name: "memory_put",
description: "Store a memory in the user's browser.",
schema: z.object({
key: z.string(),
value: z.unknown(),
}),
});
export const memoryGet = tool({
name: "memory_get",
description: "Look up a memory stored in the user's browser.",
schema: z.object({
key: z.string(),
}),
});
export const geolocationGet = tool({
name: "geolocation_get",
description: "Get the user's current location from the browser.",
schema: z.object({
save: z.boolean().optional(),
}),
});import { createAgent } from "langchain";
import { MemorySaver } from "@langchain/langgraph";
import { geolocationGet, memoryGet, memoryPut } from "./tools";
export const agent = createAgent({
model: "openai:gpt-5.5",
tools: [memoryPut, memoryGet, geolocationGet],
checkpointer: new MemorySaver(),
});实现浏览器端行为
将仅限客户端的行为放在一个独立的模块中,并使用 .implement(...) 进行附加。真实的 Playground 包含一个更完整的 IndexedDB 存储,具有搜索、列出、过期和删除操作。以下示例从高层结构展示了相同的形态:
import {
geolocationGet as geolocationGetDefinition,
memoryGet as memoryGetDefinition,
memoryPut as memoryPutDefinition,
} from "./tools";
async function saveMemory(key: string, value: unknown) {
localStorage.setItem(`agent-memory:${key}`, JSON.stringify(value));
}
async function getMemory(key: string) {
const value = localStorage.getItem(`agent-memory:${key}`);
return value ? JSON.parse(value) : null;
}
export const memoryPut = memoryPutDefinition.implement(async ({ key, value }) => {
await saveMemory(key, value);
return { success: true, key };
});
export const memoryGet = memoryGetDefinition.implement(async ({ key }) => {
const value = await getMemory(key);
return value === null ? { found: false, key } : { found: true, key, value };
});
export const geolocationGet = geolocationGetDefinition.implement(
async ({ save = true }) => {
const position = await new Promise<GeolocationPosition>((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject),
);
const location = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
};
if (save) {
await saveMemory("user_location", location);
}
return location;
},
);将实现接入 useStream
将实现后的工具传递给 useStream。当 Agent 发出匹配的工具调用时,Hook 会运行客户端实现并为你恢复运行。
Agent 状态可以从 Agent 定义中推断出来:
import type { myAgent } from "./agent";
export type AgentState = typeof myAgent;import { useStream } from "@langchain/react";
import { geolocationGet, memoryGet, memoryPut } from "./impl";
import type { AgentState } from "./types";
const AGENT_URL = "http://localhost:2024";
export function Chat() {
const stream = useStream<AgentState>({
apiUrl: AGENT_URL,
assistantId: "headless_tools",
tools: [memoryPut, memoryGet, geolocationGet],
});
return <ChatView messages={stream.messages} toolCalls={stream.toolCalls} />;
}<script setup lang="ts">
import { useStream } from "@langchain/vue";
import { geolocationGet, memoryGet, memoryPut } from "./impl";
import type { AgentState } from "./types";
const AGENT_URL = "http://localhost:2024";
const stream = useStream<AgentState>({
apiUrl: AGENT_URL,
assistantId: "headless_tools",
tools: [memoryPut, memoryGet, geolocationGet],
});
`</script>`
<template>
<ChatView
:messages="stream.messages.value"
:tool-calls="stream.toolCalls.value"
/>
</template><script lang="ts">
import { useStream } from "@langchain/svelte";
import { geolocationGet, memoryGet, memoryPut } from "./impl";
import type { AgentState } from "./types";
const AGENT_URL = "http://localhost:2024";
const { messages, toolCalls } = useStream<AgentState>({
apiUrl: AGENT_URL,
assistantId: "headless_tools",
tools: [memoryPut, memoryGet, geolocationGet],
});
`</script>`
<ChatView messages={$messages} toolCalls={$toolCalls} />import { Component } from "@angular/core";
import { useStream } from "@langchain/angular";
import { geolocationGet, memoryGet, memoryPut } from "./impl";
import type { AgentState } from "./types";
const AGENT_URL = "http://localhost:2024";
@Component({
selector: "app-chat",
template: `
<app-chat-view
[messages]="stream.messages()"
[toolCalls]="stream.toolCalls()"
/>
`,
})
export class ChatComponent {
stream = useStream<AgentState>({
apiUrl: AGENT_URL,
assistantId: "headless_tools",
tools: [memoryPut, memoryGet, geolocationGet],
});
}内联渲染工具活动
Playground 将每个内存或地理位置操作渲染为其独立的卡片,并在输入框附近保留一个小型内存统计面板。关键步骤是将 stream.toolCalls 中的每个条目匹配回触发它的 AI 消息:
import type { ToolCallWithResult, DefaultToolCall } from "@langchain/react";
function Message({ message, toolCalls }: {
message: AIMessage,
toolCalls: ToolCallWithResult[]
}) {
const messageToolCalls = toolCalls.filter((tc) =>
message.tool_calls?.some((call) => call.id === tc.call.id),
);
return (
<div>
{message.text && <p>{message.text}</p>}
{messageToolCalls.map((tc) => (
<HeadlessToolCard key={tc.call.id} toolCall={tc} />
))}
</div>
);
}这与工具调用中更丰富的 UI 模式配合得尤为出色,在工具调用中,每个工具结果都可以渲染为专门的卡片,而不是原始的 JSON。
使用场景
当任务依赖仅存在于客户端的 API 或数据时,请使用 Headless 工具:
- IndexedDB 或
localStorage中的本地存储 - 地理位置、剪贴板、摄像头或文件选择器等设备 API
- Canvas、音频或其他仅限浏览器的渲染原语
- 应当保留在用户设备上的隐私敏感数据
- 需要直接访问内存中前端状态的 UI 操作
最佳实践
- 保持工具体量小且类型明确。相较于一个通用的“运行任意浏览器代码”工具,优先使用多个职责单一的工具。
- 返回可 JSON 序列化的结果。切勿尝试返回 DOM 节点、文件句柄或其他不可序列化的浏览器对象。
- 共享定义,分离实现。Agent 与客户端应在工具名称和模式(schema)上保持一致,但只有客户端应该加载浏览器 API。
- 在 UI 中呈现工具状态。使用
stream.toolCalls和onTool来展示待处理(pending)、成功(success)和错误(error)状态。 - 必要时添加审核。对于敏感的客户端操作,请将此模式与人在回路结合使用。
