Skip to content

Headless 工具 ​

通过 Headless 工具实现在客户端运行浏览器和设备 API

Headless 工具允许你的 Agent 调用其实际执行必须在用户应用中而非服务器上发生的工具。Agent 看到的仍然是普通的工具模式(schema),但其实现在前端中运行,可以在前端访问浏览器 API,例如 IndexedDB、地理位置(geolocation)、剪贴板(clipboard)、Canvas 或文件选择器(file pickers)。

当数据应当保留在设备本地时,这种模式尤其有用。本页上的 Playground 示例使用了一个由 IndexedDB 支持的小型浏览器内存工具包,以及一个完全在客户端运行的地理位置工具。

Headless 工具的工作原理 ​

从高层架构来看,Headless 工具将工具模式(schema)与纯浏览器端实现分离开来。

  1. 在 Agent 上注册仅包含模式(schema-only)的工具定义。
  2. 在前端使用 .implement(...) 实现相匹配的工具。
  3. 将这些实现传递给 useStream({ tools: [...] })。
  4. 当 Agent 发出匹配的工具调用时,客户端会执行它,并使用工具结果恢复被中断的运行。

TIP

将工具定义与实现分别存放在独立的模块中。在 Agent 和前端之间共享这些定义,以确保工具名称和模式保持一致,然后将纯浏览器端代码保留在仅限客户端的 impl 模块中。

在 Agent 上注册工具 ​

Playground 定义了一组遵循相同模式的客户端工具:Agent 公开工具模式(schema),而前端处理实际执行。

在共享的 tools.ts 文件中定义一次工具,并在 Agent 和前端两处都使用该文件。

ts
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(),
  }),
});
ts
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 存储,具有搜索、列出、过期和删除操作。以下示例从高层结构展示了相同的形态:

impl.ts
ts
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 定义中推断出来:

ts
import type { myAgent } from "./agent";

export type AgentState = typeof myAgent;
tsx
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} />;
}
vue
<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],
});
`&lt;/script&gt;`

<template>
  <ChatView
    :messages="stream.messages.value"
    :tool-calls="stream.toolCalls.value"
  />
</template>
svelte
<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],
  });
`&lt;/script&gt;`

<ChatView messages={$messages} toolCalls={$toolCalls} />
ts
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 消息:

tsx
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)状态。
  • 必要时添加审核。对于敏感的客户端操作,请将此模式与人在回路结合使用。