Skip to content

无头工具(Headless tools)让你的智能体能够调用那些真实执行必须发生在用户应用里而不是服务器上的工具。智能体仍然看到一个普通的工具 schema,但实现位于前端,在那里它可以访问浏览器 API,如 IndexedDB、地理位置、剪贴板、canvas 或文件选择器。

这种模式在数据应当保留在设备本地时尤其有用。本页的 playground 示例使用了一个由 IndexedDB 支撑的小型浏览器记忆工具包,外加一个完全在客户端运行的地理位置工具。

import { PatternEmbed } from "/snippets/pattern-embed.jsx"

无头工具的工作原理

在高层面上,无头工具将工具 schema 与仅限浏览器的实现分离。

  1. 在智能体上注册一个立即调用 interrupt() 以将执行推迟到前端的工具。

  2. 在前端定义中镜像相同的工具名称与参数字段。

  3. 在前端使用 .implement(...) 实现匹配的工具,并将它们传给 useStream({ tools: [...] })

  4. 当智能体调用匹配的工具时,客户端处理该操作,并以工具结果恢复被中断的运行。

  5. 在智能体上注册一个仅含 schema 的工具定义。

  6. 在前端使用 .implement(...) 实现匹配的工具。

  7. 将这些实现传给 useStream({ tools: [...] })

  8. 当智能体发出匹配的工具调用时,客户端运行它,并以工具结果恢复被中断的运行。

TIP

将工具定义与实现放在单独的模块中。在你的智能体与前端之间共享定义,使工具名称与 schema 保持对齐,然后将仅限浏览器的代码放在一个仅限客户端的 impl 模块中。

在智能体上注册工具

playground 定义了一小组遵循相同模式的客户端工具:智能体暴露工具 schema,前端处理实际执行。

在服务器上定义普通的工具,这些工具立即调用 interrupt(),然后在前端 tools.ts 文件中镜像相同的工具名称与参数字段。

python
from typing import Any

from langchain import create_agent
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt
from pydantic import BaseModel

class MemoryPutInput(BaseModel):
    key: str
    value: Any

class MemoryGetInput(BaseModel):
    key: str

class GeolocationGetInput(BaseModel):
    save: bool = True

def _interrupt_for_client(
    tool_name: str,
    args: dict[str, Any],
    runtime: ToolRuntime,
) -> Any:
    return interrupt({
        "type": "tool",
        "tool_call": {
            "id": runtime.tool_call_id,
            "name": tool_name,
            "args": args,
        },
    })

@tool(
    "memory_put",
    description="Store a memory in the user's browser.",
    args_schema=MemoryPutInput,
)
def memory_put(key: str, value: Any, runtime: ToolRuntime) -> Any:
    return _interrupt_for_client(
        "memory_put",
        {"key": key, "value": value},
        runtime,
    )

@tool(
    "memory_get",
    description="Look up a memory stored in the user's browser.",
    args_schema=MemoryGetInput,
)
def memory_get(key: str, runtime: ToolRuntime) -> Any:
    return _interrupt_for_client("memory_get", {"key": key}, runtime)

@tool(
    "geolocation_get",
    description="Get the user's current location from the browser.",
    args_schema=GeolocationGetInput,
)
def geolocation_get(runtime: ToolRuntime, save: bool = True) -> Any:
    return _interrupt_for_client(
        "geolocation_get",
        {"save": save},
        runtime,
    )

agent = create_agent(
    model="openai:gpt-5.5",
    tools=[memory_put, memory_get, geolocation_get],
    checkpointer=MemorySaver(),
)

每个工具都以前端可以处理的、结构化 payload 进行中断,然后在运行恢复时返回提供的值。在客户端镜像相同的工具名称与 schema,以便前端可以附加实现。

ts
import * as z from "zod";
import { tool } from "langchain";

// 在客户端镜像 Python 工具名称与 schema。
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(),
  }),
});

在共享的 tools.ts 文件中一次性定义工具,并从智能体与前端两侧使用该文件。

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 存储,支持搜索、列表、过期与删除操作。下面的示例在更高层面上展示了相同的形式:

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。当智能体发出匹配的工具调用时,该 hook 会运行客户端实现,并为你恢复运行。

定义一个与你智能体状态 schema 匹配的 TypeScript 接口,并将其作为类型参数传给 useStream,以便对状态值进行类型安全访问:

ts
export interface AgentState {
  messages: BaseMessage[];
}

智能体状态可以从智能体定义中推断出来:

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],
});
</script>

<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],
  });
</script>

<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 (
      {message.text && {message.text}}
      {messageToolCalls.map((tc) => (
        <HeadlessToolCard key={tc.call.id} toolCall={tc} />
      ))}
  );
}

这一点配合 工具调用 中更丰富的 UI 模式效果尤其好,在那里每个工具结果都可以渲染为专门的卡片,而不是原始 JSON。

使用场景

当工作依赖仅存在于客户端中的 API 或数据时,使用无头工具:

  • IndexedDB 或 localStorage 中的本地记忆
  • 设备 API,如地理位置、剪贴板、摄像头或文件选择器
  • Canvas、音频或其他仅限浏览器的渲染原语
  • 应保留在用户设备上的隐私敏感数据
  • 需要直接访问内存中前端状态的 UI 操作

最佳实践

  • 保持工具小而带类型。优先使用许多窄用途工具,而不是一个通用的"运行任意浏览器代码"工具。
  • 返回可 JSON 序列化的结果。不要尝试返回 DOM 节点、文件句柄或其他不可序列化的浏览器对象。
  • 共享定义、分离实现。智能体与客户端应在工具名称与 schema 上保持一致,但只有客户端应加载浏览器 API。
  • 在界面(UI)中展示工具状态。使用 stream.toolCallsonTool 来展示待处理、成功与错误状态。
  • 在需要时添加审查。对于敏感的客户端操作,将此模式与人在回路结合使用。