[LangGrpah] Tool calls demo

Overview

A small demo, using LangGrpah to calling real tool to write an artical about the topic user defined.

Project structure

tool-calls
├── output
│   ├── 当 TypeScript 遇见 Go:一次关于“换芯”的工程化重塑之旅.md
│   └── 当 TypeScript 遇见 Go:一次关于“换芯”的工程化重塑之旅.png
├── package.json
├── pnpm-lock.yaml
└── src
    ├── graph
    │   ├── agent.ts
    │   ├── content_node.ts
    │   ├── image_node.ts
    │   ├── summary_node.ts
    │   └── title_node.ts
    ├── index.ts
    ├── model.ts
    ├── prompt.ts
    ├── state.ts
    └── tools
        ├── index.ts
        └── search.ts

grahp: define all the nodesl, with an exported agent
tools: define all the tools

Code

state.ts

import { z } from "zod/v4";

// 整张图状态的Schema
export const Schema = z.object({
  topic: z.string().describe("文章主题"),
  title: z.string().describe("文章标题"),
  content: z.string().describe("文章内容"),
  summary: z.string().describe("文章摘要"),
  image_path: z.string().describe("图片路径"),
});

// 基于Schema生成的ts类型
export type TArticle = z.infer<typeof Schema>;

model.ts

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

export function getChatGPT(): ChatOpenAI {
  return new ChatOpenAI({
    model: "gpt-5.1",
    temperature: 0.7,
  });
}

prompt.ts:

// 系统提示词
export const SYSTEM_PROMPT = `
你是一位资深的内容编辑,擅长各种题材的写作。
你正在撰写一篇主题为 {topic} 的文章,字数在6000个汉字左右,你力求做到结构严谨、行文优美、可读性强。
`;

// 标题提示词
export const TITLE_PROMPT = `
结合指定的主题,给文章起一个贴切的标题。
特别注意:只生成一个最合适的标题即可,以纯文本格式返回,无需生成其他额外信息。
`;

// 内容提示词
export const CONTENT_PROMPT = `
文章的标题为:《{title}》
请先根据文章主题,在互联网上检索相关资料。
接下来,根据资料的检索结果,按照总-分-总的结构,生成文章的主要段落。段落数在3~6段之间,可以结合你的经验和主题灵活调整。

下面是一个可供参考的段落结构:
1. xxx综述
2. xxx的历史演进
3. xxx的核心概念
4. xxx实战
5. xxx总结

格式要求:生成的内容纯文本格式,每一段都要有一个二级标题(##)。段落之间保留2行空行。
`;

// 文章摘要
export const SUMMARY_PROMPT = `
现在,文章已经写好了,请你根据文章的主题、标题和正文内容,生成一段30~60个汉字的摘要,总结这篇文章的核心内容。

标题:{title}
正文内容:{content}
`;

// 文章图片
export const IMAGE_PROMPT = `
你是一位资深的插画师,擅长为各种文章创作精美的插图。

现在有一篇文章,具体信息如下:
主题:{topic}
标题:{title}
摘要:{summary}

请你为该文章创造一副插画,展示文章所要传达的核心内容。要求画质精美、符合逻辑、表达力强,且意境深远,给人无尽回味。
特别注意:如果文章中包含文字,需要确保文字表达通顺。
`;

index.ts

import "dotenv/config";
import { buildGraph, writeArticle, dumpMarkdown } from "./graph/agent.ts";

// 入口文件
async function main() {
  // 1. 构造agent
  const agent = buildGraph();

  // 2. 写文章:agent、主题
  const finalState = await writeArticle(agent, "typescript用go语言换芯");

  // 3. 将文章导出为markdown格式
  dumpMarkdown(finalState);
}

main();

tools/search.ts

// 搜索工具
import { z } from "zod";
import { tool } from "@langchain/core/tools";

// 工具方法参数 schema
const schema = z.object({
  query: z.string().describe("搜索的关键字"),
});

export type searchInput = z.infer<typeof schema>;

const func = async ({ query }: searchInput): Promise<string> => {
  console.log(`正在调用工具 [search] 进行搜索,搜索的关键词为:${query}`);

  // SerpAPI
  const baseUrl = "https://serpapi.com/search.json";
  const apiKey = process.env.SERPER_API_KEY;

  if (!apiKey) throw new Error("缺少Serp Api Key");

  // 构建url的查询参数
  const params = new URLSearchParams({
    engine: "google",
    q: query,
    api_key: apiKey,
    gl: "cn",
    hl: "zh-cn",
  });

  try {
    const response = await fetch(`${baseUrl}?${params.toString()}`);

    if (!response.ok) throw new Error(`搜索失败,状态码为${response.status}`);

    const json = await response.json();

    if (json.organic_results && json.organic_results.length > 0) {
      // 说明这一次搜索是有效的
      return json.organic_results[0].snippet;
    }
    return "没有搜索到相关结果";
  } catch (err) {
    console.error("Search工具出现错误,错误信息为:", err);
    return "搜索错误";
  }
};

const search = tool(func, {
  name: "search",
  description: "根据关键词,在互联网上检索相关的信息",
  schema,
});

export default search;

graph/title_node.ts

import type { TArticle } from "../state.ts";
import { SYSTEM_PROMPT, TITLE_PROMPT } from "../prompt.ts";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { getChatGPT } from "../model.ts";
import { StringOutputParser } from "@langchain/core/output_parsers";

export async function titleNode(state: TArticle): Promise<Partial<TArticle>> {
  if (!state.topic) {
    throw new Error("主题不能为空");
  }

  const topic = state.topic;

  const pt = ChatPromptTemplate.fromMessages([
    ["system", SYSTEM_PROMPT],
    ["human", TITLE_PROMPT],
  ]);

  const model = getChatGPT();

  const chain = pt.pipe(model).pipe(new StringOutputParser());

  const title = await chain.invoke({
    topic,
  });

  console.log(`[TitleNode] 文章标题:${title}`);

  return {
    title,
  };
}

graph/content_node.ts

Using ReAct design go get result

import { ChatPromptTemplate } from "@langchain/core/prompts";
import { SYSTEM_PROMPT, CONTENT_PROMPT } from "../prompt.ts";
import type { TArticle } from "../state.ts";
import {
  type BaseMessageLike,
  HumanMessage,
  SystemMessage,
  ToolMessage,
} from "@langchain/core/messages";
import { getChatGPT } from "../model.ts";
import tools from "../tools/index.ts";

export async function contentNode(state: TArticle): Promise<Partial<TArticle>> {
  if (!state.topic) {
    throw new Error("主题不能为空");
  }

  if (!state.title) {
    throw new Error("标题不能为空");
  }

  const topic = state.topic;
  const title = state.title;

  const systemContent = SYSTEM_PROMPT.replace("{topic}", topic);
  const userContent = CONTENT_PROMPT.replace("{title}", title);

  const messages: BaseMessageLike[] = [
    new SystemMessage(systemContent),
    new HumanMessage(userContent),
  ];

  const model = getChatGPT();

  const modelWithTools = model.bindTools(tools);

  // ReAct循环
  // 1. 模型推理
  // 2. action
  while (true) {
    const reply = await modelWithTools.invoke(messages);
    messages.push(reply);

    // 接下来就需要对 reply 进行一个判断,reply 的形态决定了是否退出当前的循环
    /**
     * reply是一个对象:
     * 情况一:模型给你的回复是调用工具
     * {
     *  content: "",
     *  tool_calls: [
     *      {name: "search", args: {query: "..."}, id: "call_xxx"}
     *  ]
     * }
     * 情况二:模型生成了最终的答复
     * {
     *  content: "文章具体的内容.....",
     *  tool_calls: []
     * }
     */
    if (!reply.tool_calls || reply.tool_calls.length === 0) {
      // 说明此时是情况二,不需要调用工具,模型已经生成了文章的完整内容
      // 此节点可以结束了,可以进入下一个节点
      const content = reply.content as string;
      console.log(`文章的正文部分已经生成完毕,共 ${content.length} 字`);
      return { content };
    }

    // 如果没有进入上面的 if,那么就调用工具
    /**
     * tool_calls: [
     *      {name: "search", args: {query: "..."}, id: "call_xxx", description: "xxxxx"},
     *      {name: "calc", args: {a: "...", b: "xxx"}, id: "call_xxx", description: "xxxxx"},
     *  ]
     */
    for (const toolCall of reply.tool_calls) {
      // 从当前的工具箱里面去寻找对应的工具
      const selectedTool = tools.find((tool) => tool.name === toolCall.name);
      if (selectedTool) {
        // 从工具箱找到了相应的工具
        // 既然找到了,那么就执行工具
        const toolResult = await (selectedTool as any).invoke(toolCall.args);
        console.log(
          `[${toolCall.name}] 工具调用已经完成,工具调用结果为:${toolResult}`,
        );
        messages.push(
          new ToolMessage({
            content: toolResult,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
          }),
        );
      } else {
        console.warn(`没有找到名为${toolCall.name}的工具`);
      }
    }
  }
}

graph/image_node.ts

import type { TArticle } from "../state.ts";
import * as fs from "fs";

/**
 *
 * @param url 图片的下载地址
 * @param filePath 图片的保存地址
 */
async function downloadImage(url: string, filePath: string) {
  const response = await fetch(url);

  if (!response.ok) throw new Error("下载图片失败");
  const buffer = await response.arrayBuffer(); // 拿到一个图片流
  fs.writeFileSync(filePath, Buffer.from(buffer));
}

export async function imageNode(state: TArticle): Promise<Partial<TArticle>> {
  if (!state.summary) {
    throw new Error("摘要不能为空");
  }

  const summary = state.summary;

  try {
    const apiKey = process.env.OPENAI_API_KEY;

    if (!apiKey) {
      throw new Error("缺少OpenAI API Key");
    }

    const response = await fetch(
      "https://api.openai.com/v1/images/generations",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          model: "dall-e-3",
          prompt: summary,
          n: 1,
          size: "1024x1024",
        }),
      },
    );

    if (!response.ok) {
      throw new Error(`图片生成失败,状态码为${response.status}`);
    }

    const data = await response.json();

    if (!data.data || data.data.length === 0 || !data.data[0].url) {
      throw new Error("图片生成失败,没有返回数据");
    }

    const imageUrl = data.data[0].url;
    console.log(`图片生成成功,图片URL为:${imageUrl}`);

    // 接下来我们需要将这个临时地址的图片赶紧下载下来
    const imagePath = `./${state.title}.png`;

    await downloadImage(imageUrl, imagePath); // 进行图片的下载
    console.log(`文章插图已经下载完毕,保存在:${imagePath} 路径下`);

    return {
      image_path: imageUrl,
    };
  } catch (error) {
    console.error("图片生成失败,错误信息为:", error);
    throw error;
  }
}

graph/agent.ts

import { END, START, StateGraph } from "@langchain/langgraph";
import { Schema, type TArticle } from "../state.ts";
import { imageNode } from "./image_node.ts";
import { summaryNode } from "./summary_node.ts";
import { titleNode } from "./title_node.ts";
import { contentNode } from "./content_node.ts";
import fs from "fs";

export function buildGraph() {
  return new StateGraph(Schema)
    .addNode("title_node", titleNode)
    .addNode("summary_node", summaryNode)
    .addNode("image_node", imageNode)
    .addNode("content_node", contentNode)
    .addEdge(START, "title_node")
    .addEdge("title_node", "content_node")
    .addEdge("content_node", "summary_node")
    .addEdge("summary_node", "image_node")
    .addEdge("image_node", END)
    .compile();
}

export async function writeArticle(agent: any, topic: string) {
  // 初始状态
  const initState: TArticle = {
    topic,
    title: "",
    content: "",
    summary: "",
    image_path: "",
  };
  console.log(`智能编辑已经开始撰写文章,文章的主题为:${topic}`);
  return await agent.invoke(initState);
}

// 创建md文件
export function dumpMarkdown(state: TArticle) {
  const { title, content, image_path } = state;
  const filename = `./${title}.md`;

  let mdContent = `# ${title}\n\n`;
  mdContent += `${content}\n\n`;
  mdContent += `![${title}](${image_path})\n\n`;

  // 写入
  fs.writeFileSync(filename, mdContent);
  console.log(`文章已经生成完毕,保存至:${filename} 位置`);
}
posted @ 2026-02-23 14:37  Zhentiw  阅读(2)  评论(0)    收藏  举报