跳到内容

如何让 Agent 以结构化格式响应

典型的 ReAct Agent 提示 LLM 以两种格式之一响应:调用函数(~ JSON)以使用工具,或对话文本以响应用户。

如果您的 Agent 连接到结构化(甚至生成式)UI,或者它正在与另一个 Agent 或软件进程通信,您可能希望它以特定的结构化格式响应。

在本例中,我们将构建一个会话式 ReAct Agent,它以特定格式响应。我们将通过使用工具调用来实现这一点。当您想强制 Agent 的响应采用特定格式时,这非常有用。在本例中,我们将要求它像天气预报员一样响应,在单独的机器可读字段中返回温度和其他信息。

设置

首先,我们需要安装所需的软件包

yarn add langchain @langchain/anthropic @langchain/langgraph @langchain/core

接下来,我们需要为 OpenAI(我们将使用的 LLM)设置 API 密钥。

// process.env.OPENAI_API_KEY = "sk_...";

可选地,我们可以为 LangSmith 追踪设置 API 密钥,这将为我们提供一流的可观测性。

// process.env.LANGCHAIN_API_KEY = "ls...";
process.env.LANGCHAIN_CALLBACKS_BACKGROUND = "true";
process.env.LANGCHAIN_TRACING_V2 = "true";
process.env.LANGCHAIN_PROJECT = "Respond in Format: LangGraphJS";
Respond in Format: LangGraphJS

设置状态

import { Annotation, messagesStateReducer } from "@langchain/langgraph";
import { BaseMessage } from "@langchain/core/messages";

const GraphState = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer,
  }),
});

设置工具

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const searchTool = tool((_) => {
  // This is a placeholder, but don't tell the LLM that...
  return "67 degrees. Cloudy with a chance of rain.";
}, {
  name: "search",
  description: "Call to surf the web.",
  schema: z.object({
    query: z.string().describe("The query to use in your search."),
  }),
});

const tools = [searchTool];

我们现在可以将这些工具包装在 ToolNode 中。

import { ToolNode } from "@langchain/langgraph/prebuilt";

const toolNode = new ToolNode<typeof GraphState.State>(tools);

设置模型

import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  temperature: 0,
  model: "gpt-4o",
});

完成此操作后,我们应确保模型知道它可以调用这些工具。我们可以通过将 LangChain 工具绑定到模型类来实现这一点。

我们还想为语言模型定义一个响应模式,并将其作为工具绑定到模型。我们的想法是,当模型准备好响应时,它将调用这个最终工具,并根据我们想要的模式填充参数。我们将从图中返回,而不是调用工具。

因为我们只打算使用这个最终工具来指导模型最终响应的模式,所以我们将使用模拟函数声明它

import { tool } from "@langchain/core/tools";

const Response = z.object({
  temperature: z.number().describe("the temperature"),
  other_notes: z.string().describe("any other notes about the weather"),
});

const finalResponseTool = tool(async () => "mocked value", {
  name: "Response",
  description: "Always respond to the user using this tool.",
  schema: Response
})

const boundModel = model.bindTools([
  ...tools,
  finalResponseTool
]);

定义节点

import { AIMessage } from "@langchain/core/messages";
import { RunnableConfig } from "@langchain/core/runnables";

// Define the function that determines whether to continue or not
const route = (state: typeof GraphState.State) => {
  const { messages } = state;
  const lastMessage = messages[messages.length - 1] as AIMessage;
  // If there is no function call, then we finish
  if (!lastMessage.tool_calls || lastMessage.tool_calls.length === 0) {
    return "__end__";
  }
  // Otherwise if there is, we need to check what type of function call it is
  if (lastMessage.tool_calls[0].name === "Response") {
    return "__end__";
  }
  // Otherwise we continue
  return "tools";
};

// Define the function that calls the model
const callModel = async (
  state: typeof GraphState.State,
  config?: RunnableConfig,
) => {
  const { messages } = state;
  const response = await boundModel.invoke(messages, config);
  // We return an object, because this will get added to the existing list
  return { messages: [response] };
};

定义图

import { StateGraph } from "@langchain/langgraph";

// Define a new graph
const workflow = new StateGraph(GraphState)
  .addNode("agent", callModel)
  .addNode("tools", toolNode)
  .addEdge("__start__", "agent")
  .addConditionalEdges(
    // First, we define the start node. We use `agent`.
    // This means these are the edges taken after the `agent` node is called.
    "agent",
    // Next, we pass in the function that will determine which node is called next.
    route,
    // We supply a map of possible response values to the conditional edge
    // to make it possible to draw a visualization of the graph.
    {
      __end__: "__end__",
      tools: "tools",
    }
  )
  // We now add a normal edge from `tools` to `agent`.
  // This means that after `tools` is called, `agent` node is called next.
  .addEdge("tools", "agent");

// Finally, we compile it!
// This compiles it into a LangChain Runnable,
// meaning you can use it as you would any other runnable
const app = workflow.compile();
import * as tslab from "tslab";

const graph = app.getGraph();
const image = await graph.drawMermaidPng();
const arrayBuffer = await image.arrayBuffer();

await tslab.display.png(new Uint8Array(arrayBuffer));

使用它!

我们现在可以使用它了!现在它公开了与所有其他 LangChain Runnables 相同的接口

import { HumanMessage, isAIMessage } from "@langchain/core/messages";

const prettyPrint = (message: BaseMessage) => {
  let txt = `[${message._getType()}]: ${message.content}`;
  if (
    isAIMessage(message) && message?.tool_calls?.length
  ) {
    const tool_calls = message?.tool_calls
      ?.map((tc) => `- ${tc.name}(${JSON.stringify(tc.args)})`)
      .join("\n");
    txt += ` \nTools: \n${tool_calls}`;
  }
  console.log(txt);
};

const inputs = {
  messages: [new HumanMessage("what is the weather in sf")],
};

const stream = await app.stream(inputs, { streamMode: "values" });

for await (const output of stream) {
  const { messages } = output;
  prettyPrint(messages[messages.length - 1]);
  console.log("\n---\n");
}
[human]: what is the weather in sf

---

[ai]:  
Tools: 
- search({"query":"current weather in San Francisco"})

---

[tool]: 67 degrees. Cloudy with a chance of rain.

---

[ai]:  
Tools: 
- Response({"temperature":67,"other_notes":"Cloudy with a chance of rain."})

---

部分流式 JSON

如果我们想在结构化输出可用时立即对其进行流式传输,我们可以使用 .streamEvents() 方法。我们将聚合发出的 on_chat_model_events 并检查 name 字段。一旦我们检测到模型正在调用最终输出工具,我们就可以开始记录相关的块。

这是一个例子

import { concat } from "@langchain/core/utils/stream";

const eventStream = await app.streamEvents(inputs, { version: "v2" });

let aggregatedChunk;
for await (const { event, data } of eventStream) {
  if (event === "on_chat_model_stream") {
    const { chunk } = data;
    if (aggregatedChunk === undefined) {
      aggregatedChunk = chunk;
    } else {
      aggregatedChunk = concat(aggregatedChunk, chunk);
    }
    const currentToolCalls = aggregatedChunk.tool_calls;
    if (
      currentToolCalls.length === 0 ||
      currentToolCalls[0].name === "" ||
      !finalResponseTool.name.startsWith(currentToolCalls[0].name)
    ) {
      // No tool calls or a different tool call in the message,
      // so drop what's currently aggregated and start over
      aggregatedChunk = undefined;
    } else if (currentToolCalls[0].name === finalResponseTool.name) {
      // Now we're sure that this event is part of the final output!
      // Log the partially aggregated args.
      console.log(aggregatedChunk.tool_call_chunks[0].args);

      // You can also log the raw args instead:
      // console.log(chunk.tool_call_chunks);

      console.log("---");
    }
  }
}
// Final aggregated tool call
console.log(aggregatedChunk.tool_calls);
---
{"
---
{"temperature
---
{"temperature":
---
{"temperature":67
---
{"temperature":67,"
---
{"temperature":67,"other
---
{"temperature":67,"other_notes
---
{"temperature":67,"other_notes":"
---
{"temperature":67,"other_notes":"Cloud
---
{"temperature":67,"other_notes":"Cloudy
---
{"temperature":67,"other_notes":"Cloudy with
---
{"temperature":67,"other_notes":"Cloudy with a
---
{"temperature":67,"other_notes":"Cloudy with a chance
---
{"temperature":67,"other_notes":"Cloudy with a chance of
---
{"temperature":67,"other_notes":"Cloudy with a chance of rain
---
{"temperature":67,"other_notes":"Cloudy with a chance of rain."
---
{"temperature":67,"other_notes":"Cloudy with a chance of rain."}
---
{"temperature":67,"other_notes":"Cloudy with a chance of rain."}
---
[
  {
    name: 'Response',
    args: { temperature: 67, other_notes: 'Cloudy with a chance of rain.' },
    id: 'call_oOhNx2SdeelXn6tbenokDtkO',
    type: 'tool_call'
  }
]