# 搜索代理
## 一、什么是搜索代理
在聊搜索代理之前，就需要先搞清楚代理是什么，简单理解可以是：**LLM 在自主循环中使用工具**

🌟 那么搜索代理的简单定义：**LLM 使用各种搜索或检索工具，动态地，按需地获取相关上下文**


我在测试各种模型对于检索工具的调用的时候，发现模型能力差异导致的搜索结果不同，优秀的模型的搜索路径规划的更加合理，并且搜索的方向非常准确，而能力较差的模型，方向飘忽不定，结果也很一般

> 在文章的最后我会附上测试日志：[deepseek-菜谱 share.log](https://github.com/WakeUp-Jin/Practical-Guide-to-Context-Engineering/blob/main/docs/%E6%90%9C%E7%B4%A2%E4%BB%A3%E7%90%86/deepseek-%E8%8F%9C%E8%B0%B1%20share.log)
>

所以我发现影响搜索代理成功的因素之一是：更智能的模型，可以使搜索代理自主应对复杂问题并从错误中恢复

## 二、搜索代理和 RAG 的区别
Excaildraw 文件：[https://gcntfv628ebr.feishu.cn/file/OoMPbYlVKoTxNmxZGhZcNt2znWe](https://gcntfv628ebr.feishu.cn/file/OoMPbYlVKoTxNmxZGhZcNt2znWe)

![搜索代理和 RAG 的区别](./image/image%20(33).png)

搜索代理和 RAG 的最主要的区别在于工作执行流程：

+ RAG 是预推理检索：系统预先从向量数据库检索出来结果之后输入给 LLM，这个时候 **LLM 是被动的接受**
+ 搜索代理是即时检索：**LLM 是主动决策者**，由 LLM 来决定搜索什么，怎么搜索，并且可以根据中间结果来主动的调整搜索路径


搜索代理和 RAG 还有一个在**优化行为方式的区别：**

搜索代理中有一种工具是 `glob`，这个工具是用来搜索和匹配文件名的，这个时候文件名也可以成为有效搜索因素，当一个代码库中的文件结构非常清晰，并且文件命名合理，在这种工作空间下搜索代理会非常有效，搜索代理的影响因素如下：

+ 文件夹的结构： 例如：`tests/test_agent.ts` 这个就表示测试模块中的测试 agent 功能的文件的隐性含义
+ 命名约定：例如：`*.config.js` 表示配置文件 、`README.md` 表示文档说明
+ 文件大小：可以隐含复杂性的背景信息
+ 时间戳：可以表示文件是最近修改的，隐含该文件相关性比较强(因为这个文件是最近修改的，那用户输入的问题很大概率是因为他刚刚修改这个文件产生的，我们可以仔细想想自己使用 ClaudeCode 或者 Cursor 的 Agent 模式的时候是不是这样的呢）

在这种构建方式下，代理可以逐层构建理解，仅在工作记忆中保留必要信息，**这种自我管理的上下文窗口使代理专注于相关子集，而不是被大量但可能无关的信息淹没。**

而对于 RAG 来说，只能重新构建嵌入文档块了，而不是像搜索代理这样随心所欲的动态更换一下文件名就可以，这也是一个关键区别**搜索代理的维护成本比较低，开发起来比较快**

当然，事物总是相对的，RAG 还是有优秀的地方的

+ 在大量数据时，RAG 的检索会比搜索代理快很多
+ RAG 的开发构建策略是平稳的，有成熟的构建搜索方案：分叉索引、父文档索引等，但是搜索代理要有效的话，需要有深思熟虑的工程实践，要有成熟的指导开发方案，不如搜索代理会很容易进入死循环和无效检索来浪费上下文，在这个方向上，搜索代理的开发难度比 RAG 大多了

那我们来总结一下 RAG 和搜索代理的区别点

1. **工作执行流程的区别**，RAG 是预推理检索，搜索代理是即时检索
2. **优化行为方式的区别**，搜索代理的优化方向更多，策略是可以动态调整的，RAG 需要重新构建文档块
3. **开发和维护成本的区别**：搜索代理的维护成本比较低，文件系统工具即时开发，不需要像 RAG 那样整理文档块，然后构建向量数据库等，并且搜索代理调整和维护比较容易
4. **大量数据的检索速度区别**：在大量数据的前提下，RAG 检索速度回比搜索代理快很多
5. **有效搜索代理开发难度大**：要想搜索代理在实际的场景中有效，需要有合适的策略指导
6. **概念级匹配**：按代码“做什么”来查找，而不只是按名称,RAG 向量的语义搜索能够理解代码背后的含义。

📝 Agent 同时使用 grep 和语义搜索。Grep 擅长查找 精确模式，语义搜索擅长查找概念相似性。二者结合可获得最佳效果。

## 三、搜索代理的应用场景
1、 **对于上下文信息密度有较高的要求**：例如编程开发领域，开发者使用代码来实现自己严密的业务逻辑，这种场景下使用代理搜索会非常合适

倘若我们采用 RAG 的方式来检索，我们需要对代码库进行文档块的分割，就不能像文本那样简单的按照行数来分割了，因为代码上下文之间有很强的逻辑依赖，如果文档块中少一行 if 判断，表达的意思就是天差地别了，比较合适的的方法是结合语法树和代码依赖关联来进行分割，

所以这种情况下，不仅存入向量数据库的过程更麻烦，检索时也因为内部机制不透明，导致开发者很难看清检索的全貌，调试起来也更困难。

2、**动态上下文不多**：要动态传入给 LLM 的动态外部数据不多的时候，搜索代理很适用，例如：中小型代码库、一些公司内部客服手册这种相对固定的数据

我非常推荐开发者在构建大模型应用初期的时候，完全可以优先考虑使用搜索代理来实现外部数据的引入，在应用构建初期的时候，遵循着快速迭代的原则，搜索代理的开发难度和开发效率相比复杂的 RAG 是有优势的


3、**偏向简单有效原则构建 Agent**：大模型应用开发这个领域是在快速发展的，而且大部分都是向着“LLM 越来越智能”这个方向前行，构建简单的模块和应用是最合适的，方便应用和模块进行迭代，像渐进式构建有效应用的原则一样


## 四、具体实现
Excalidraw 文件：[https://gcntfv628ebr.feishu.cn/file/OoMPbYlVKoTxNmxZGhZcNt2znWe](https://gcntfv628ebr.feishu.cn/file/OoMPbYlVKoTxNmxZGhZcNt2znWe)

![具体实现](./image/image%20(34).png)

我是使用 Ts 来实现的，我比较熟悉这个语言，我的实现比较简单，没有做过多的兼容，只保证自己本地运行成功，在实现的过程中我发现一些复杂的地方：

+ 一个成熟的执行工具需要兼容多种系统的版本，这个时候是比较复杂的
+ 工具的描述和名称要定义的合理，表达的清晰，不然模型会混乱调用

### 4.1、工具实现
#### 4.1.1、GrepTool 工具实现
实现 GrepTool 工具对象时，需要一些工具的参数定义，比较重要的是工具参数

+ pattern：搜索的正则表达式
+ path：搜索路径（目录）
+ include：文件模式过滤

::: details 点击展开完整代码
```typescript
import { InternalTool, InternalToolContext } from '../types.js';
import { ripGrep } from '../../utils/ripgrep.js';

export const TOOL_NAME_FOR_PROMPT = 'GrepTool';
export const DESCRIPTION = `
- 适用于任何代码库大小的快速内容搜索工具
- 使用正则表达式搜索文件内容
- 支持完整的正则表达式语法（例如 "log.*Error"、"function\\s+\\w+" 等）
- 使用 include 参数按模式过滤文件（例如 "*.js"、"*.{ts,tsx}"）
- 返回按修改时间排序的匹配文件路径
- 当你需要查找包含特定模式的文件时使用此工具
- 当你进行可能需要多轮 glob 和 grep 的开放式搜索时，请改用 Agent 工具
`;


/**
 * GrepTool 参数定义
 */
export interface GrepToolArgs {
  /** 搜索的正则表达式模式 */
  pattern: string;
  /** 搜索路径（目录） */
  path?: string;
  /** 文件模式过滤 */
  include?: string;
}

/**
 * GrepTool 返回结果
 */
export interface GrepToolResult {
  /** 匹配的文件路径 */
  matches: string[];
  /** 匹配总数 */
  count: number;
}

/**
 * GrepTool 处理函数
 */
const grepToolHandler = async (
  args: GrepToolArgs,
  context?: InternalToolContext
): Promise<GrepToolResult> => {
  const { pattern, path = context?.cwd || process.cwd(), include } = args;

  // 构建 ripgrep 参数
  const rgArgs: string[] = [];

  // 只返回文件路径
  rgArgs.push('-l'); // --files-with-matches

  // 文件模式过滤
  if (include) {
    rgArgs.push('--glob', include);
  }

  // 搜索模式
  rgArgs.push(pattern);

  // 执行搜索
  const abortSignal = context?.abortSignal || new AbortController().signal;
  const results = (await ripGrep(rgArgs, path, abortSignal)) as string[];

  return {
    matches: results,
    count: results.length,
  };
};

/**
 * GrepTool 工具定义
 */
export const GrepTool: InternalTool<GrepToolArgs, GrepToolResult> = {
  name: 'grep_search',
  category: 'search',
  internal: true,
  description: DESCRIPTION,
  version: '1.0.0',
  parameters: {
    type: 'object',
    properties: {
      pattern: {
        type: 'string',
        description:
          'The regular expression pattern to search for in file contents',
      },
      path: {
        type: 'string',
        description:
          'The directory to search in. Defaults to the current working directory.',
      },
      include: {
        type: 'string',
        description:
          'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
      },
    },
    required: ['pattern'],
  },
  handler: grepToolHandler,
};

```
:::


在实现这个 grepTool 工具函数的时候，使用的是 ripgrep 命令，这个命令执行的速度非常快，比一般的搜索命令要快很多，在构建这个命令执行的函数需要注意两点：

1. Mac 系统需要构建执行文件的签名
2. 命令执行的时候需要判断，如果本地电脑没有 rg 这个命令，就需要使用 vendor 里面的二进制文件来执行了

> 使用命令 ripgrep：[https://github.com/BurntSushi/ripgrep](https://github.com/BurntSushi/ripgrep)
>

::: details 点击展开完整代码
```typescript
import { fileURLToPath } from 'url';
import path from 'path';
import { findActualExecutable } from 'spawn-rx';
import { execFile } from 'child_process';
import { execFileNoThrow } from './execFileNoThrow.js';
import { config } from '../config/env.js';

const __filename = fileURLToPath(import.meta.url);
let __dirname = path.dirname(__filename);

console.log(config.nodeEnv);

if (config.nodeEnv === 'development' || config.nodeEnv === 'test') {
  __dirname = path.resolve(__dirname, '..', '..');
}

function ripgrepPath() {
  //检查本地是否安装了ripgrep
  const { cmd } = findActualExecutable('rg', []);

  if (cmd !== 'rg') {
    return cmd;
  } else {
    const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep');
    console.log(rgRoot);

    const ret = path.resolve(
      rgRoot,
      `${process.arch}-${process.platform}`,
      'rg'
    );
    console.log(ret);
    return ret;
  }
}

export async function ripGrep(
  args: string[],
  target: string,
  abortSignal: AbortSignal
) {
  //mac电脑的签名
  await macSignature();
  const rgPath = ripgrepPath();

  return new Promise(resolve => {
    execFile(
      ripgrepPath(),
      [...args, target],
      {
        maxBuffer: 1_000_000,
        signal: abortSignal,
        timeout: 10_000,
      },
      (error, stdout) => {
        if (error) {
          if (error.code !== 1) {
            console.log(error);
          }
          resolve([]);
        } else {
          resolve(stdout.split('\n').filter(Boolean));
        }
      }
    );
  });
}

//mac电脑的签名
let alreadSign = false;
async function macSignature() {
  if (process.platform !== 'darwin') {
    return '';
  }
  alreadSign = true;

  console.log('开始验证签名...');
  const lines = (
    await execFileNoThrow(
      'codesign',
      ['-vv', '-d', ripgrepPath()],
      undefined,
      undefined,
      false
    )
  ).stdout.split('\n');
  console.log(lines);
  const needsSigned = lines.find(line => line.includes('linker-signed'));
  if (!needsSigned) {
    console.log('不需要签名');
    return;
  }

  try {
    console.log('生成ripgrep签名');
    const signResult = await execFileNoThrow('codesign', [
      '--sign',
      '-',
      '--force',
      '',
      '--preserve-metadata=entitlements,requirements,flags,runtime',
      ripgrepPath(),
    ]);
    if (signResult.code !== 0) {
      console.log('签名失败', signResult.stderr);
    }

    console.log('移除应用隔离标记');
    const quarantineResult = await execFileNoThrow('xattr', [
      '-d',
      'com.apple.quarantine',
      ripgrepPath(),
    ]);
    if (quarantineResult.code !== 0) {
      console.log('移除应用隔离标记失败', quarantineResult.stderr);
    }

    console.log('签名成功');
  } catch (error) {
    console.error('签名生成失败', error);
  }
}
```
:::

#### 4.1.2、GlobTool 工具实现
实现 GlobTool 工具对象的时候，相应的工具参数的定义：

+ pattern：Glob 匹配模式
+ path：搜索路径

::: details 点击展开完整代码
```typescript
import { InternalTool, InternalToolContext } from '../types.js';
import { glob } from '../../utils/file.js';
export const TOOL_NAME_FOR_PROMPT = 'GlobTool';

export const DESCRIPTION = `- 适用于任何代码库大小的快速文件模式匹配工具
- 支持像 "**/*.js" 或 "src/**/*.ts" 这样的 glob 模式
- 返回按修改时间排序的匹配文件路径
- 当你需要通过名称模式查找文件时使用此工具
- 当你进行可能需要多轮 glob 和 grep 的开放式搜索时，请改用 Agent 工具
`;
/**
 * GlobTool 参数定义
 */
export interface GlobToolArgs {
  /** Glob 匹配模式 */
  pattern: string;
  /** 搜索路径（目录） */
  path?: string;
}

/**
 * GlobTool 返回结果
 */
export interface GlobToolResult {
  /** 匹配的文件路径 */
  files: string[];
  /** 是否被截断 */
  truncated: boolean;
  /** 匹配总数 */
  count: number;
}

/**
 * GlobTool 处理函数
 */
const globToolHandler = async (
  args: GlobToolArgs,
  context?: InternalToolContext
): Promise<GlobToolResult> => {
  const { pattern, path = context?.cwd || process.cwd() } = args;

  // 执行 glob 搜索
  const abortSignal = context?.abortSignal || new AbortController().signal;
  const result = await glob(
    pattern,
    path,
    { limit: 100, offset: 0 },
    abortSignal
  );

  return {
    files: result.files,
    truncated: result.truncated,
    count: result.files.length,
  };
};

/**
 * GlobTool 工具定义
 */
export const GlobTool: InternalTool<GlobToolArgs, GlobToolResult> = {
  name: 'glob_search',
  category: 'search',
  internal: true,
  description: DESCRIPTION,
  version: '1.0.0',
  parameters: {
    type: 'object',
    properties: {
      pattern: {
        type: 'string',
        description:
          'The glob pattern to match files (e.g., "**/*.ts", "src/**/*.{js,jsx}")',
      },
      path: {
        type: 'string',
        description:
          'The directory to search in. Defaults to the current working directory.',
      },
      offset: {
        type: 'number',
        description: 'Number of results to skip (for pagination, default: 0)',
      },
      limit: {
        type: 'number',
        description: 'Maximum number of results to return (default: 100)',
      },
    },
    required: ['pattern'],
  },
  handler: globToolHandler,
};

```
:::


这个命令的实现相对来说比较简单，使用 glob 框架就可以搞定

```typescript [glob.ts]
import { glob as globLib } from 'glob';

//匹配搜索文件目录下的文件
export async function glob(
  filePattern: string,
  cwd: string,
  { limit, offset }: { limit: number; offset: number },
  abortSignal: AbortSignal
) {
  const paths = await globLib([filePattern], {
    cwd,
    nocase: true,
    nodir: true,
    signal: abortSignal,
    stat: true,
    withFileTypes: true,
  });

  const sortedPaths = paths.sort((a, b) => (a.mtimeMs ?? 0) - (b.mtimeMs ?? 0));
  const truncated = sortedPaths.length > offset + limit;

  return {
    files: sortedPaths
      .slice(offset, offset + limit)
      .map(path => path.fullpath()),
    truncated,
  };
}
```


#### 4.1.3、FileReadTool 工具实现
实现这个文件内容读取的工具对象，增加了一个类似于分页读取内容的功能，避免一次读取太多，相应的工具参数定义：

+ file_path：文件路径
+ offset：起始行号
+ limit：最大读取行数

::: details 点击展开完整代码
```typescript
import { InternalTool, InternalToolContext } from '../types.js';
import { readFileContent } from '../../utils/file.js';
const MAX_LINES_TO_READ = 2000;
const MAX_LINE_LENGTH = 2000;

export const DESCRIPTION = '从本地文件系统读取文件。';
export const PROMPT = `从本地文件系统读取文件。file_path 参数必须是绝对路径，而不是相对路径。默认情况下，它从文件开头读取最多 ${MAX_LINES_TO_READ} 行。你可以选择指定行偏移量和限制（对于长文件特别有用），但建议通过不提供这些参数来读取整个文件。任何超过 ${MAX_LINE_LENGTH} 个字符的行将被截断。对于图像文件，该工具将为你显示图像。`;

/**
 * FileReadTool 参数定义
 */
export interface FileReadToolArgs {
  /** 文件路径 */
  file_path: string;
  /** 起始行号（从0开始） */
  offset?: number;
  /** 最大读取行数 */
  limit?: number;
}

/**
 * FileReadTool 返回结果
 */
export interface FileReadToolResult {
  /** 文件内容 */
  content: string;
  /** 返回的行数 */
  lineCount: number;
  /** 文件总行数 */
  totalLines: number;
  /** 文件路径 */
  filePath: string;
}

/**
 * FileReadTool 处理函数
 */
const fileReadToolHandler = async (
  args: FileReadToolArgs,
  context?: InternalToolContext
): Promise<FileReadToolResult> => {
  const { file_path, offset = 0, limit } = args;

  // 读取文件内容
  const result = await readFileContent(file_path, offset, limit);

  return {
    content: result.content,
    lineCount: result.lineCount,
    totalLines: result.totalLines,
    filePath: file_path,
  };
};

/**
 * FileReadTool 工具定义
 */
export const FileReadTool: InternalTool<FileReadToolArgs, FileReadToolResult> =
  {
    name: 'read_file',
    category: 'filesystem',
    internal: true,
    description: DESCRIPTION,
    version: '1.0.0',
    parameters: {
      type: 'object',
      properties: {
        file_path: {
          type: 'string',
          description: 'The absolute or relative path to the file to read',
        },
        offset: {
          type: 'number',
          description: 'Starting line number (0-indexed, default: 0)',
        },
        limit: {
          type: 'number',
          description:
            'Maximum number of lines to read (omit to read entire file)',
        },
      },
      required: ['file_path'],
    },
    handler: fileReadToolHandler,
  };

```
:::


这个文件内容读取工具使用的是 Node 的 fs 模块中的文件读取命令，需要重点注意的是文件编码格式要获取，不一定文件编码格式都是 utf-8

```typescript [readFileContent.ts]
//读取文件内容
export async function readFileContent(
  filePath: string,
  offset: number = 0,
  maxLines?: number
): Promise<{ content: string; lineCount: number; totalLines: number }> {
  //TODO:获取文件的编码格式 - 默认UTF-8这个原本是需要写一个文件编码获取的函数
  const enc = 'utf-8';
  //读取文件内容
  const content = await readFileSync(filePath, enc);

  //按行切割 - 跨平台的兼容要使用"/\r?\n/"
  const lines = content.split('\n');

  //整理返回结果
  const toReturn =
    maxLines !== undefined && lines.length - offset > maxLines
      ? lines.slice(offset, offset + maxLines)
      : lines.slice(offset);

  return {
    content: toReturn.join('\n'),
    lineCount: toReturn.length,
    totalLines: lines.length,
  };
}
```


### 4.1.4、ListDirectoryTool 工具实现
该工具是文件目录结构查询，工具函数的参数只有一个：

+ path：要列出目录的路径

::: details 点击展开完整代码
```typescript
import { InternalTool, InternalToolContext } from '../types.js';
import {
  listDirectory,
  createFileTree,
  printTree,
} from '../../utils/listDirectory.js';

export const DESCRIPTION =
  '列出指定路径中的文件和目录。path 参数必须是绝对路径，而不是相对路径。如果你知道要搜索哪些目录，通常应优先使用 Glob 和 Grep 工具。';

/**
 * ListDirectoryTool 参数定义
 */
export interface ListDirectoryToolArgs {
  /** 要列出的目录路径 */
  path?: string;
}

/**
 * ListDirectoryTool 返回结果
 */
export interface ListDirectoryToolResult {
  /** 文件树格式输出 */
  tree: string;
  /** 文件路径列表 */
  files: string[];
  /** 总数 */
  count: number;
}

/**
 * ListDirectoryTool 处理函数
 */
const listDirectoryToolHandler = async (
  args: ListDirectoryToolArgs,
  context?: InternalToolContext
): Promise<ListDirectoryToolResult> => {
  const { path = context?.cwd || process.cwd() } = args;

  // 执行目录列出
  const abortSignal = context?.abortSignal || new AbortController().signal;
  const files = listDirectory(path, context?.cwd || process.cwd(), abortSignal);

  // 创建文件树
  const tree = createFileTree(files);
  const treeOutput = printTree(tree);

  return {
    tree: treeOutput,
    files,
    count: files.length,
  };
};

/**
 * ListDirectoryTool 工具定义
 */
export const ListDirectoryTool: InternalTool<
  ListDirectoryToolArgs,
  ListDirectoryToolResult
> = {
  name: 'list_directory',
  category: 'filesystem',
  internal: true,
  description: DESCRIPTION,
  version: '1.0.0',
  parameters: {
    type: 'object',
    properties: {
      path: {
        type: 'string',
        description:
          'The directory path to list. Defaults to the current working directory.',
      },
    },
    required: [],
  },
  handler: listDirectoryToolHandler,
};

```
:::


在实现该工具函数的中，没有使用递归函数去实现，担心栈溢出，使用的是一个广度搜索，需要注意的是两个格式化的函数

+ 将搜索出来的文件数组转换为 Tree 格式的数据结构
+ 将 Tree 格式的数据结构转换为空格隔开的目录字符串

::: details 点击展开完整代码
```typescript
import { readdirSync } from 'fs';
import { basename, join, relative, sep } from 'path';
import { getCwd } from './common.js';

export function listDirectory(
  initialPath: string,
  cwd: string,
  abortSignal: AbortSignal
): string[] {
  const results: string[] = [];

  const queue = [initialPath];

  while (queue.length > 0) {
    if (results.length > 500) {
      return results;
    }

    if (abortSignal.aborted) {
      return results;
    }
    const path: any = queue.shift();

    if (skip(path)) {
      continue;
    }

    if (path !== initialPath) {
      results.push(relative(cwd, path) + sep);
    }

    let children;
    try {
      children = readdirSync(path, {
        withFileTypes: true,
      });
    } catch (error) {
      console.error(error);
      continue;
    }

    for (const child of children) {
      if (child.isDirectory()) {
        queue.push(join(path, child.name) + sep);
      } else {
        const fileName = join(path, child.name);
        if (skip(fileName)) {
          continue;
        }
        results.push(relative(cwd, fileName));
        if (results.length > 500) {
          return results;
        }
      }
    }
  }

  return results;
}

function skip(path: string): boolean {
  if (path !== '.' && basename(path).startsWith('.')) {
    return true;
  }
  if (path.includes(`__pycache__${sep}`)) {
    return true;
  }
  return false;
}

/**
 * 为了让结果更加可读和节省Token，要创建两个对于结果进行格式化的函数
 *  - createFileTree
 *  - printTree
 */

type TreeNode = {
  name: string;
  path: string;
  type: 'file' | 'directory';
  children?: TreeNode[];
};
export function createFileTree(sortedPaths) {
  const root: TreeNode[] = [];

  for (const path of sortedPaths) {
    const parts = path.split(sep);
    let currentLevel: any = root;
    let currentPath = '';

    for (let i = 0; i < parts.length; i++) {
      const part = parts[i];
      if (!part) {
        continue;
      }
      currentPath = currentPath ? `${currentPath}${sep}${part}` : part;
      const isLastPart = i === parts.length - 1;

      const existingNode = currentLevel.find(node => node.name === part);

      if (existingNode) {
        currentLevel = existingNode.children || [];
      } else {
        const newNode: TreeNode = {
          name: part,
          path: currentPath,
          type: isLastPart ? 'file' : 'directory',
        };

        if (!isLastPart) {
          newNode.children = [];
        }

        currentLevel.push(newNode);
        currentLevel = newNode.children || [];
      }
    }
  }

  return root;
}

/**
 * eg.
 * - src/
 *   - index.ts
 *   - utils/
 *     - file.ts
 * @param tree
 * @param level
 * @param prefix
 * @returns
 */
export function printTree(tree: TreeNode[], level = 0, prefix = ''): string {
  let result = '';

  if (level == 0) {
    result += `- ${getCwd()}${sep}\n`;
    prefix = '  ';
  }

  for (const node of tree) {
    result += `${prefix}${'-'} ${node.name}${node.type === 'directory' ? sep : ''}\n`;

    if (node.children && node.children.length > 0) {
      result += printTree(node.children, level + 1, `${prefix}  `);
    }
  }

  return result;
}

```
:::

### 4.2、工具管理
我们定义好这些工具之后，需要将这个工具提供给 LLM，并且 LLM 返回工具执行命令的时候，需要调用相应工具的函数执行，最后将结果返回给 LLM，所以这里需要创建一个工具管理器

我的实现思路：

+ 提供一个方法用来注册工具函数：`registerAllTools`
+ 提供一个方法用来获取注册好的工具函数：`getTools`
+ 提供一个工具执行函数：`execute`

```typescript [toolManager.ts]
import {
  InternalTool,
  InternalToolContext,
  FormattedToolDefinition,
} from './types.js';
import { GrepTool } from './GrepTool/GrepTool.js';
import { ListDirectoryTool } from './ListDirectoryTool/ListDirectoryTool.js';
import { GlobTool } from './GolbTool/GlobTool.js';
import { FileReadTool } from './FileReadTool/FileReadTool.js';

const toolsList = [GrepTool, GlobTool, ListDirectoryTool, FileReadTool];

/**
 * 工具管理类
 * 负责工具的注册、查询和执行
 */
export class ToolManager {
  private tools: Map<string, InternalTool> = new Map();

  constructor() {
    // 在构造函数中自动注册所有工具
    this.registerAllTools();
  }

  /**
   * 注册所有工具
   * 显式列出所有要注册的工具
   */
  private registerAllTools() {
    const tools = [...toolsList];

    tools.forEach(tool => {
      this.tools.set(tool.name, tool);
    });
  }

  /**
   * 执行指定工具
   * @param name 工具名称
   * @param args 工具参数
   * @param context 可选的上下文参数（如 abortSignal）
   * @returns 工具执行结果
   */
  async execute<TArgs = any, TResult = any>(
    name: string,
    args: TArgs,
    context?: InternalToolContext
  ): Promise<TResult> {
    const tool = this.tools.get(name);

    if (!tool) {
      throw new Error(
        `Tool '${name}' not found. Available tools: ${this.getToolNames().join(', ')}`
      );
    }

    try {
      const result = await tool.handler(args, context);
      return result;
    } catch (error) {
      console.error(`Tool '${name}' execution failed:`, error);
      throw error;
    }
  }

  /**
   * 获取所有已注册的工具
   * @returns 工具数组
   */
  getTools(): InternalTool[] {
    return Array.from(this.tools.values());
  }
}

```


在使用的时候，因为供应商的不同，对于 api 中的 tool 参数的格式也是不一样的，所以这个需要在定义 LLM 类的时候，自己提供一个工具格式转换方法，例如 Openai 的参数格式是

```typescript
  //Format tools for OpenAI API format,Converts ToolSet to array of OpenAI tool definitions
  private formatToolsForAPI(tools: ToolSet): OpenAITool[] {
    return Object.values(tools).map(tool => ({
      type: 'function' as const,
      function: {
        name: tool.name,
        description: tool.description || '',
        parameters: tool.parameters || {
          type: 'object' as const,
          properties: {},
        },
      },
    }));
  }
```

### 4.3、LLM 工具调用
Excalidraw 文件：[https://gcntfv628ebr.feishu.cn/file/BrjYbAJsPoAwXuxKmkWczabUnOh](https://gcntfv628ebr.feishu.cn/file/BrjYbAJsPoAwXuxKmkWczabUnOh)

![LLM 工具调用](./image/image%20(35).png)

关于 LLM 的调用中，最核心的思路是：**LLM 循环执行并且判断，并且设置最大的循环数，不要陷入死循环**

::: details 点击展开完整代码
```typescript
  async generate(
    userInput: string,
    imageData?: any,
    stream: boolean = false
  ): Promise<string> {
    try {
      // DeepSeek doesn't support images, so we ignore imageData parameter
      if (imageData) {
        console.warn(
          'DeepSeek does not support image inputs. Image data will be ignored.'
        );
      }

      // Build messages array
      const messages = this.formatMessages(userInput);

      // Get tools if available
      const tools = await this.getAllTools();
      const hasTools = tools && Object.keys(tools).length > 0;

      // Tool calling loop
      let iteration = 0;
      let finalResponse = '';

      while (iteration < this.maxIterations) {
        iteration++;
        console.log(`\n=== Iteration ${iteration}/${this.maxIterations} ===`);
        appendFileSync(
          'deepseek.log',
          `\n=== Iteration ${iteration}/${this.maxIterations} ===`
        );

        // Make API call
        const response = await this.openai.chat.completions.create({
          model: this.model,
          messages,
          stream,
          ...(hasTools && { tools: this.formatToolsForAPI(tools) }),
        });

        if (stream) {
          // Handle streaming response
          let streamContent = '';
          for await (const chunk of response) {
            const content = chunk.choices[0]?.delta?.content || '';
            streamContent += content;
            if (this.eventManager) {
              this.eventManager.emit('stream', { content });
            }
          }
          finalResponse = streamContent;
          break; // Streaming doesn't support tool calls in chunks
        }

        const message = response.choices[0]?.message;
        const finishReason = response.choices[0]?.finish_reason;

        console.log('Finish reason:', finishReason);
        if (message?.content) {
          console.log('Assistant:', message.content);
          appendFileSync('deepseek.log', `\nAssistant:${message.content}`);
        }

        // Check if model wants to call tools
        if (finishReason === 'tool_calls' && message?.tool_calls) {
          console.log(`Tool calls detected: ${message.tool_calls.length}`);
          appendFileSync(
            'deepseek.log',
            `\nTool calls detected: ${message.tool_calls.length}`
          );
          // Add assistant message with tool_calls to context
          messages.push({
            role: 'assistant',
            content: message.content || '',
            tool_calls: message.tool_calls,
          });

          // Execute each tool call
          for (const toolCall of message.tool_calls) {
            const toolName = toolCall.function.name;
            const toolArgs = JSON.parse(toolCall.function.arguments);

            console.log(`Executing tool: ${toolName}`, toolArgs);
            appendFileSync(
              'deepseek.log',
              `\nExecuting tool: ${JSON.stringify(toolName)} ${JSON.stringify(toolArgs)}`
            );
            try {
              // Execute tool using tool manager
              const toolResult = await this.toolManager?.execute(
                toolName,
                toolArgs
              );

              console.log(
                `Tool result:${JSON.stringify(toolResult).slice(0, 500)}`
              );
              appendFileSync(
                'deepseek.log',
                `\nTool result:${JSON.stringify(toolResult).slice(0, 500)}`
              );
              // Add tool result to messages
              messages.push({
                role: 'tool',
                tool_call_id: toolCall.id,
                content: JSON.stringify(toolResult),
              });
            } catch (error) {
              console.error(`Tool execution error:`, error);
              appendFileSync('deepseek.log', `\nTool execution error:${error}`);
              // Add error message as tool result
              messages.push({
                role: 'tool',
                tool_call_id: toolCall.id,
                content: JSON.stringify({
                  error: error instanceof Error ? error.message : String(error),
                }),
              });
            }
          }

          // Continue loop to send tool results back to model
          continue;
        }

        // No more tool calls, return final response
        finalResponse = message?.content || '';
        break;
      }

      if (iteration >= this.maxIterations) {
        console.warn(`Reached maximum iterations (${this.maxIterations})`);
        appendFileSync(
          'deepseek.log',
          `\nReached maximum iterations (${this.maxIterations})`
        );
        if (!finalResponse) {
          finalResponse =
            'I apologize, but I reached the maximum number of iterations while processing your request. Please try breaking down your question into smaller parts.';
        }
      }

      return finalResponse;
    } catch (error) {
      this.handleError(error, 'DeepseekService.generate');
    }
  }
```
:::


## 五、最后
在执行这个搜索代理的时候，我更换了不同的模式进行测试，并且打印出来了搜索策略，我有一些发现可以分享给大家
我简单测试了这几种模型：DeepSeekV3.2、Qwen3、Glm-4.6、Gemini-2.5Pro、Gemini-2.5flash、Claude-sonnet-4.5、GPT-5
我发现表现最好的确实是 Claude 和 GPT，但是里面有一些细微的差别，我的感觉是
- Claude 搜索的策略方向非常正确，而且可以感觉到有步骤的在执行，不会过度的去无脑的搜索，像是一位非常聪明的几何图形问题论证家，解一步，验证一步，在搜索一步，就是感觉优雅
- GPT 的表现也很好，我感觉到它的小心翼翼，是小心翼翼的确定方向，一旦方向确定就会大胆的探索，像是一位稳重成熟的探险家，有谨慎又有强大的执行力