LlamaIndex官方揭秘:如何构建安全的AI编码智能体

0 阅读9分钟

前言

大家好,我是倔强青铜三。欢迎关注我,微信公众号:倔强青铜三。欢迎点赞、收藏、关注,一键三连!!!

随着 Vibe Coding(氛围编码)的兴起,软件开发领域对编码智能体(Coding Agents)的使用显著增加,尤其是基于终端或 IDE 的智能体(如 Claude Code 或 Cursor)。

伴随着这种日益增长的应用,一个挑战凸显出来:文件系统的访问权限。

具体来说:

  • 处理写入或编辑文件的权限,避免这些操作导致代码库或其他重要文件的意外删除
  • 为智能体提供对非结构化文档(PDF、演示文稿、Google/Word 文档)的深度理解能力,以便它们能够正确处理自动化和知识工作

在本文中,我们将尝试找到这两个问题的解决方案,我们将使用 LlamaParse、LlamaIndex Agent Workflows、Claude Agent SDK 和 AgentFS 来实现。

本文的所有代码可在以下地址获取:github.com/run-llama/agentfs-claude

文件系统虚拟化和其他魔法技巧

我们列出的第一个挑战与给编码智能体访问文件系统的权限有关,同时仍要保持高水平的控制。

解决这个问题的一种方法是频繁使用人机协作(human-in-the-loop):虽然这是一种高成功率的策略(大多数人可以识别危险操作并在发生之前阻止它们),但它破坏了编码智能体应该提供的自主性。不断地让人参与意味着智能体无法在后台运行,并且始终需要一定程度的关注。

第二种解决方法,反直觉的是,禁止智能体访问你的实际文件系统,让它在虚拟化副本中工作。这个选项允许智能体执行各种操作,即使是最具破坏性的操作,也不会损坏你的文件,因为一切都是用副本进行的,而不是真实文档。

为了演示这第二种选项,我们将使用 AgentFS,这是一个由 Turso 设计的高性能、基于 SQLite 的虚拟文件系统,它也可以作为键值缓存和工具调用注册表使用。

使用 AgentFS TypeScript SDK,我们可以构建多个实用程序来从 AgentFS 数据库加载、检索和修改文件。以下是一个示例:

export async function readFile(
  filePath: string,
  agentfs: AgentFS,
): Promise<string | null> {
  let content: string | null = null;
  try {
    content = (await agentfs.fs.readFile(filePath, "utf-8")) as string;
  } catch (error) {
    console.error(error);
  }
  return content;
}

一旦我们定义了所有文件系统操作的功能(读取、写入、编辑、检查存在性和列出目录中的文件),我们就使用它们在 Claude Code 的 SDK MCP 中创建自定义工具,即 filesystem MCP。以下是如何实现的代码片段:

// 定义 Zod schema 形状
const readSchemaShape = {
  filePath: z.string().describe("Path of the file to read"),
};

// 将 schema 转换为 Zod 对象
const readSchema = z.object(readSchemaShape);

// 创建一个辅助函数来连接 AgentFS 数据库
export async function getAgentFS({
  filePath = null,
}: {
  filePath?: string | null;
}): Promise<AgentFS> {
  if (!filePath) {
    filePath = "fs.db";
  }
  const agentfs = await AgentFS.open({ id: "claude-agentfs", path: filePath });
  return agentfs;
}

// 基于上面的读取函数定义读取工具
async function readTool(
  input: z.infer<typeof readSchema>,
): Promise<CallToolResult> {
  const agentfs = await getAgentFS({});
  const content = await readFile(input.filePath, agentfs);
  if (typeof content == "string") {
    return { content: [{ type: "text", text: content }] };
  } else {
    return {
      content: [
        {
          type: "text",
          text: `Could not read ${input.filePath}. Please check that the file exists and submit the request again.`,
        },
      ],
      isError: true,
    };
  }
}

// 转换为 Claude Agent SDK Tool
const mcpReadTool = tool(
  "read_file",
  "Read a file by passing its path.",
  readSchemaShape,
  readTool,
);

// 在 MCP 中使用
export const fileSystemMCP = createSdkMcpServer({
  name: "filesystem-mcp",
  version: "1.0.0",
  tools: [mcpReadTool, ...],
});

由于所有工具现在都加载到 MCP 中,Claude 不需要使用其内置的文件系统工具(ReadWriteEditGlob),我们可以在智能体选项中禁用它们。为了确保智能体不会因为幻觉或不对齐而绕过这个防护措施,我们还可以定义一个特定的 PreToolUse hook(在工具执行之前运行的一些自定义逻辑)来拒绝上述文件系统工具的每次调用。

// 定义函数
async function denyFileSystemToolsHook(
  _input: PreToolUseHookInput,
  _toolUseId: string | undefined,
  _options: { signal: AbortSignal },
): Promise<HookJSONOutput> {
  return {
    async: true,
    hookSpecificOutput: {
      permissionDecision: "deny",
      permissionDecisionReason:
        "You cannot use standard file system tools, you should use the ones from the filesystem MCP server.",
      hookEventName: "PreToolUse",
    },
  };
}

// 将其列为 hook
const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {
  PreToolUse: [
    {
      matcher: "Read|Write|Edit|Glob",
      hooks: [denyFileSystemToolsHook],
    } as HookCallbackMatcher,
  ],
};

使用 hooks,我们还可以添加两个在 filesystem MCP 的 writeedit 工具之后运行的 hook(PostToolUse),这样我们就可以询问用户是否要将更改持久化到真实文件系统中,而不仅仅是虚拟文件系统中。你可以在这个文件中找到所有 hooks 以及其他配置选项。

所有工具设置完成后,现在重要的是指导智能体如何使用它,我们可以通过自定义系统提示来实现:

export const systemPrompt = `
你是一位专家级程序员,任务是协助用户在当前工作目录中实现他们的需求。

为了执行文件系统操作,你**不得**使用内置工具(Read、Write、Glob、Edit),而**必须**使用 'filesystem' MCP 服务器,它提供以下工具:

- 'read_file':读取文件,需提供文件路径
- 'write_file':写入文件,需提供文件路径和内容
- 'edit_file':编辑文件,需提供旧字符串和要替换的新字符串
- 'list_files':列出所有可用文件
- 'file_exists':检查文件是否存在,需提供文件路径

使用这些工具,你应该能够为用户提供所需的帮助。
`;

现在 Claude Code 不仅能够使用 filesystem MCP 工具,而且它还会始终选择它们而不是内置工具:如果这没有发生,我们仍然有我们设置的 hook 来保护。

唯一的问题是,我们配置的这个文件系统只适用于基于文本的文件(如 .txt.md),因此,如果智能体想要访问 PDF 文件或其他非文本格式,它将无法做到:这个问题把我们带到了下一步,它允许我们将非结构化文件转换为机器可读的文本。

让非结构化文档变得可访问

理解复杂文档对于许多用例至关重要,尤其是当我们试图实现的最终产品绑定到特定数据集时:许多编码智能体,包括 Claude Code,对 PDF 提供基本的智能支持,但随着文件复杂性的增加,它们的性能会下降。

在我们使用 AgentFS 的演示中,我们可以利用我们已经在使用虚拟文件系统这一事实,以纯文本形式加载非结构化文件。为此,我们可以使用 LlamaParse,这是一种最先进的 OCR 和智能解析解决方案,可以从 PDF、Word 和 Google 文档、Excel 表格以及更多文件格式中提取高质量文本内容。

在为编码智能体准备文件系统环境时,我们按原样加载基于文本的文件,并使用 LlamaParse 解析非结构化文件以加载其文本内容,使 Claude Code 能够访问高质量提取的文本,并更好地理解如果项目涉及文档时的需求。

这是我们用来解析文件的函数,利用 llama-cloud-services typescript 包:

const apiKey = process.env.LLAMA_CLOUD_API_KEY;
const reader = new LlamaParseReader({
  resultType: "text",
  apiKey: apiKey,
  fastMode: true,
  checkInterval: 4,
  verbose: true,
});

export async function parseFile(filePath: string): Promise<string> {
  let text = "";
  try {
    const documents = await reader.loadData(filePath);
    for (const document of documents) {
      text += document.text;
    }
    return text;
  } catch (error) {
    console.log(error);
    return text;
  }
}

使用 Workflow 作为框架

现在我们了解了如何使用 AgentFS 设置虚拟文件系统以及如何使用 LlamaParse 加载非结构化文件,我们只需要用一个框架将所有内容整合在一起,为 Claude Code 提供一个适合编码的环境。

我们使用 LlamaIndex Workflows 来实现这一点,通过 @llamaindex/workflows-core typescript 包,它为我们提供了两个主要优势:

  • 分步执行:Claude Code 在一个独立的步骤中运行,该步骤仅在前两个步骤(在虚拟文件系统中加载文件和收集提示)成功完成后才触发
  • 人机协作:workflow 提供了简单的人机协作架构模式,它受益于维护可快照和可恢复状态的可能性。此功能允许我们在 workflow 执行期间从用户那里收集提示(以及其他选项,如计划模式激活和恢复之前的会话)

让我们看看如何实现这个 workflow:

async function main() {
  const { withState } = createStatefulMiddleware(() => ({}));
  const workflow = withState(createWorkflow());
  const startEvent = workflowEvent<{ workingDirectory: string | undefined }>();
  const filesRegisteredEvent = workflowEvent<void>();
  const requestPromptEvent = workflowEvent<void>();
  const promptEvent = workflowEvent<{
    prompt: string;
    resume: string | undefined;
    plan: boolean;
  }>();
  const stopEvent = workflowEvent<{ success: boolean; error: string | null }>();

  const notFromScratch = fs.existsSync("fs.db");

  const agentFs = await getAgentFS({});

  workflow.handle([startEvent], async (_context, event) => {
    if (notFromScratch) {
      return filesRegisteredEvent.with();
    }
    const wd = event.data.workingDirectory;
    let dirPath: string | undefined = wd;
    if (typeof wd === "undefined") {
      dirPath = "./";
    }
    const success = await recordFiles(agentFs, { dirPath: dirPath });
    if (!success) {
      return stopEvent.with({
        success: success,
        error:
          "Could not register the files within the AgentFS file system: check writing permissions in the current directory",
      });
    } else {
      return filesRegisteredEvent.with();
    }
  });

  // eslint-disable-next-line
  workflow.handle([filesRegisteredEvent], async (_context, _event) => {
    console.log(
      bold(
        "All the files have been uploaded to the AgentFS filesystem, what would you like to do now?",
      ),
    );
    return requestPromptEvent.with();
  });

  workflow.handle([promptEvent], async (_context, event) => {
    const prompt = event.data.prompt;
    const agent = new Agent(queryOptions, {
      resume: event.data.resume,
      plan: event.data.plan,
    });
    try {
      await agent.run(prompt);
      return stopEvent.with({ success: true, error: null });
    } catch (error) {
      return stopEvent.with({ success: false, error: JSON.stringify(error) });
    }
  });

  const { sendEvent, snapshot, stream } = workflow.createContext();
  sendEvent(startEvent.with({ workingDirectory: "./" }));
  await stream.until(requestPromptEvent).toArray();
  const snapshotData = await snapshot();
  const humanResponse = await consoleInput("Your prompt: ");
  console.log(
    bold("Would you like to resume a previous session? Leave blank if not"),
  );
  const resumeSession = await consoleInput("Your answer: ");
  let sessionId: string | undefined = undefined;
  if (resumeSession.trim() != "") {
    sessionId = resumeSession;
  }
  console.log(bold("Would you like to activate plan mode? [y/n]"));
  const activatePlan = await consoleInput("Your answer: ");
  let planMode = false;
  if (["yes", "y", "yse"].includes(activatePlan.trim().toLowerCase())) {
    planMode = true;
  }
  const resumedContext = workflow.resume(snapshotData);
  resumedContext.sendEvent(
    promptEvent.with({
      prompt: humanResponse,
      resume: sessionId,
      plan: planMode,
    }),
  );
  await resumedContext.stream.until(stopEvent).toArray();
}

完整的定义可以在这里 github.com/run-llama/a… 找到。

现在 workflow 已经准备就绪,剩下的就是启动第一个编码智能体会话!如果你一直在跟随仓库进行操作,你只需要运行:

pnpm run start

对于以后的会话,如果你想清理智能体文件系统数据库,你也可以运行 pnpm run clean-start

总结

在这篇博文中,我们探讨了:

  • 当前编码智能体处理文件系统管理的方法所面临的挑战
  • 使用虚拟化文件系统的优势(以及如何使用 AgentFS 设置一个)
  • 编码智能体的文档理解问题,以及如何使用 LlamaParse 作为解决方案
  • 如何将所有内容打包到 LlamaIndex Agent Workflow 中,以获得完美的受控环境

欢迎关注我的微信公众号:倔强青铜三,获取更多 AI 自动化和开发技巧分享!