从零实现 AI 编程助手:LangChain.js + ReAct 循环实战

0 阅读6分钟

Claude Code 凭借精准的代码理解、生成与调试能力,成为开发者高效编码的利器。但是核心本质还是大模型结合工具调用,实现代码场景化智能交互—— 而这一能力,我们完全可以基于 LangChain.js 复刻实现。

在此之前我们已系统掌握 LangChain.js 的核心基础:从豆包大模型的接入、invoke/stream 的调用,到 Messages 消息体系的运用,再到自定义 Tool 的开发与工具调用闭环的实现,为打造专属代码助手筑牢了技术根基。

如果你还不了解这些,可以查看这两篇文章:

本文将基于 LangChain.js 整合大模型能力与自定义工具链,手把手打造一个简易版 Claude Code。我们将本地文件读写与代码落地读写,自定义执行命令工具的全流程能力,让大模型真正成为能写、能存、能解析的专属代码助手。

一、核心能力拆解

Claude Code 的强大之处在于它能理解代码上下文执行具体操作。我们将核心能力抽象为三个基础工具,对应代码中的三大模块:

工具功能定位技术实现
read_file代码理解与分析fs.readFile 读取项目文件
write_file代码生成与落地fs.writeFile + 自动目录创建
execute_command环境交互与验证child_process.spawn 支持前后台

这三个工具构成了代码助手的最小能力闭环(感知上下文)→ (生成代码)→ 执行(验证运行)。


二、核心代码解析:工具实现细节

2.1 文件读取工具

const readFileTool = tool(
    async ({ filePath }) => {
        try {
            const content = await fs.readFile(filePath, 'utf-8');
            return `文件内容:\n${content}`;
        } catch (error) { 
            return `读取文件失败: ${error.message}`;
        }
    },
    {
        name: 'read_file',
        description: '用此工具来读取文件内容...',
        schema: z.object({
            filePath: z.string().describe('要读取的文件路径'),
        }),
    }
);

设计要点:

  • 路径兼容:支持相对/绝对路径,适配不同项目结构
  • 错误透传:将文件不存在、权限不足等错误反馈给模型,让其自主决策重试或调整策略
  • UTF-8 默认:覆盖 99% 的代码文件场景,避免编码问题干扰

当用户问"帮我分析 src/utils/helper.js 的问题"时,模型会自主调用此工具获取代码上下文,而非依赖用户手动粘贴。

2.2 文件写入工具

const writeFileTool = tool(
    async ({ filePath, content }) => {
         try {
            const dir = path.dirname(filePath);
            await fs.mkdir(dir, { recursive: true });  // 关键:自动创建目录
            await fs.writeFile(filePath, content, 'utf-8');
            return `文件写入成功: ${filePath}`;
        } catch (error) { 
            return `文件写入工具调用失败: ${error.message}`;
        }
    },
    {
        name: 'write_file',
        description: '向指定路径写入文件内容,自动创建目录',
        schema: z.object({
            filePath: z.string().describe('文件路径'),
            content: z.string().describe('写入的文件内容'),
        }),
    }
);

关键设计:recursive: true 自动创建目录链

这是 Claude Code 体验的核心——用户只需说"创建一个 React 组件 components/UserProfile/index.tsx",工具会自动处理 components/UserProfile/ 的目录创建,无需分步操作。


2.3 命令执行工具 —— 环境交互的"双脚"

这是三个工具中最复杂的,需支持交互式命令(如 npm install)和常驻服务(如 npm run dev):

const executeCommandTool = tool(
    async ({ command, workingDirectory, background = false }) => {
        const [cmd, ...args] = command.split(' ');
        
        const child = spawn(cmd, args, {
            cwd: workingDirectory || process.cwd(),
            stdio: background ? 'pipe' : 'inherit',  // 前台交互 或者 后台静默
            shell: true,
            detached: background,  // 关键:后台运行解耦
        });

        if (background) {
            child.unref();  // 允许父进程退出而子进程继续运行
            return `命令已在后台启动: ${command}`;
        } 
        // ... 前台模式等待完成
    },
    {
        name: 'execute_command',
        schema: z.object({
            command: z.string(),
            workingDirectory: z.string().optional(),  // 项目隔离
            background: z.boolean().optional(),  // 服务启动模式
        }),
    }
);

双模式设计解析:

模式场景技术特征
前台模式 (background: false)npm installgit statusstdio: 'inherit' 实时展示输出,用户体验如同在终端直接执行
后台模式 (background: true)npm run dev、数据库服务detached: true + unref() 实现服务常驻,不阻塞对话流程

workingDirectory 的重要性:

在多项目场景下,通过参数指定工作目录,避免 cd 命令的副作用和路径混乱。例如:

// 正确:直接指定目录
executeCommandTool.invoke({ 
    command: "npm run build", 
    workingDirectory: "./my-project" 
})

// 避免:依赖 cd 的不可靠方式
executeCommandTool.invoke({ command: "cd my-project && npm run build" })

为什么cd 命令不可靠?

原因1:

cd my-project && npm run build 这种写法是强依赖 shell 的语法,有些平台不支持

平台命令分隔符问题
Linux/macOS&&正常执行
Windows CMD&&支持,但行为略有差异
Windows PowerShell; 或换行&& 在旧版本不兼容

更隐蔽的是路径分隔符

# Unix
cd ./my-project && npm start

# Windows(可能失败)
cd .\my-project && npm start  # 反斜杠
# 或
cd my-project && npm start    # 驱动器根目录问题

workingDirectory 参数由 Node.js 的 path 模块统一处理,自动适配平台。

原因2:

当项目路径包含空格时,cd 命令容易解析错误:

// 危险:路径带空格导致命令被截断
executeCommandTool.invoke({ 
    command: "cd ./my project && npm start" 
})
// 实际执行:cd ./my ("project" 被当作新命令)
// 报错:/bin/sh: line 0: cd: too many arguments

// 即使加引号也复杂
executeCommandTool.invoke({ 
    command: "cd \"./my project\" && npm start" 
})
// 需要处理嵌套引号、转义,容易出错

原因3: 相对路径的基准混乱:cd 改变的是进程级的工作目录,而 spawncwd 只影响子进程

致命缺陷:background: true 模式下使用 cd 会导致目录上下文丢失

三、Agent 核心引擎

工具写好了,现在需要构建Agent 大脑——让大模型能够理解需求→决策工具→执行动作→观察结果→循环直至完成。这一节我们将实现一个支持流式输出多轮工具调用的交互式 Agent。

3.1 架构设计:ReAct 循环

ReAct(Reasoning + Acting,推理+行动)是 Agent 设计的经典范式,由 Google 在 2022 年提出。它模拟人类解决问题的思维方式:不是一次性给出答案,而是通过"思考→行动→观察"的循环逐步推进

与传统 LLM 调用的区别:

模式交互方式特点
标准 LLM一问一答依赖预训练知识,无法获取实时信息
ReAct Agent多轮工具调用通过工具与外部世界交互,动态获取信息

3.2 模型初始化与工具绑定

模块作用
ChatOpenAI大模型接口,支持工具调用(OpenAI 格式兼容)
InMemoryChatMessageHistory内存级对话历史,维护多轮上下文
JsonOutputToolsParser解析模型输出的工具调用 JSON
HumanMessage/SystemMessage/ToolMessage构建标准消息体系

为什么选择 InMemoryChatMessageHistory

作为简易版实现,内存存储足够演示核心逻辑

const model = new ChatOpenAI({
    modelName: process.env.MODEL_NAME,
    apiKey: process.env.API_KEY,
    configuration: {
        baseURL: process.env.BASE_URL,  // 兼容第三方 API(如 DeepSeek、豆包)
    },
});

const tools = [readFileTool, writeFileTool, executeCommandTool];
const modelWithTools = model.bindTools(tools);

bindTools 的关键作用:

这一步将工具定义(name/description/schema)注入模型上下文,让模型知道:

  1. 有哪些工具可用 —— 名称和描述
  2. 什么时候该用 —— 基于用户意图决策
  3. 参数怎么填 —— 遵循 Zod schema 约束

3.3 核心循环:runAgentWithTools 实现

这是整个 Agent 的心脏,实现 ReAct(推理-行动)循环:

async function runAgentWithTools(prompt, maxIterations = 30) {
    // 1. 记录用户输入
    await history.addMessage(new HumanMessage(prompt));
    let toolParser = new JsonOutputToolsParser();

    // 2. 多轮迭代,直到完成或达到上限
    for (let i = 0; i < maxIterations; i++) {
        let messages = await history.getMessages();
        console.log(`⏳ 正在等待 AI 思考...`);
        
        // 3. 流式调用模型
        let fullResponse = null;
        let hasStartedOutput = false;
        const stream = await modelWithTools.stream(messages);
        
        // 4. 实时处理流式响应
        for await (const chunk of stream) {
            // 累积完整响应(用于后续工具调用判断)
            if (!fullResponse) {
                fullResponse = chunk;
            } else {
                fullResponse = fullResponse.concat(chunk);
            }
            
            // 尝试解析工具调用(流式过程中可能不完整,用 try-catch 保护)
            let parsedTools = null;
            try {
                parsedTools = await toolParser.parseResult([{ message: fullResponse }]);
            } catch (error) {
                // 解析失败说明工具调用尚未完整,继续等待
            }

            // 5. 实时流式输出内容
            let outputContent = '';
            
            // 情况 A:普通文本回复
            if (chunk.content) {
                outputContent = chunk.content;
            }
            // 情况 B:工具调用中的代码生成(如 write_file 的内容字段)
            else if (parsedTools && parsedTools.length > 0) {
                for (const toolCallChunk of parsedTools) {
                    if (toolCallChunk.type === 'write_file' && toolCallChunk.args.content) {
                        outputContent += toolCallChunk.args.content;
                    }
                }
            }
            
            // 6. 实时打印到终端
            if (outputContent && outputContent.trim() !== '') {
                if (!hasStartedOutput) {
                    console.log(`\n✨ AI 回复:\n`);
                    hasStartedOutput = true;
                }
                process.stdout.write(outputContent);
            }
        }
        
        // 7. 保存完整响应到历史
        const response = fullResponse;
        await history.addMessage(response);

        // 8. 判断是否需要工具调用
        if (!response.tool_calls || response.tool_calls.length === 0) {
            if (hasStartedOutput) console.log('\n');
            return response.content;  // 无需工具,直接返回
        }

        if (hasStartedOutput) console.log('\n');

        // 9. 执行工具调用闭环
        for (const toolCall of response.tool_calls) {
            const foundTool = tools.find(t => t.name === toolCall.name);
            if (foundTool) {
                console.log(`  [工具调用] ${toolCall.name}(${JSON.stringify(toolCall.args)})`);
                
                // 执行工具
                const toolResult = await foundTool.invoke(toolCall.args);
                
                // 10. 将工具结果反馈给模型(关键!)
                await history.addMessage(
                    new ToolMessage({
                        content: toolResult,
                        tool_call_id: toolCall.id,  // 必须匹配,让模型知道对应哪个调用
                    })
                );
            }
        }
        // 11. 循环继续:模型收到工具结果后,决定下一步行动
    }

    return '达到最大迭代次数';
}

循环流程图解:

graph TD
    A[用户输入] --> B[添加到 History]
    B --> C[流式调用模型]
    C --> D{有工具调用?}
    D -->|否| E[直接输出回复]
    D -->|是| F[实时打印思考过程]
    F --> G[执行工具]
    G --> H[ToolMessage 反馈结果]
    H --> C
    E --> I[结束]

3.4 流式输出的工程细节

为什么要流式处理?

场景体验差异
非流式等待 10 秒后才一次性看到结果,用户以为卡死
流式立即看到 "正在分析..."、"发现依赖问题...",感知响应速度

双模式内容捕获:

代码中处理了两种输出源:

  1. chunk.content —— 模型的自然语言回复(解释思路、询问确认)
  2. toolCallChunk.args.content —— write_file 工具中的代码内容

特殊优化:代码生成实时预览

当模型调用 write_file 时,代码内容可能很长(数百行)。通过解析工具调用参数并实时打印,用户能立即看到生成的代码,而非等待整个文件写入完成。

// 关键代码段:提取 write_file 中的代码内容实时展示
if (toolCallChunk.type === 'write_file' && toolCallChunk.args.content) {
    outputContent += toolCallChunk.args.content;
}

3.5 工具调用闭环:为什么 ToolMessage 至关重要

这是最容易被忽视但最核心的机制

await history.addMessage(
    new ToolMessage({
        content: toolResult,           // 工具执行结果(成功/失败/输出)
        tool_call_id: toolCall.id,     // 必须匹配模型的 tool_call.id
    })
);

闭环逻辑:

  1. 模型发出指令:"请执行 read_file('package.json'),我需要查看依赖"
  2. 系统执行工具:读取文件,得到内容 { "dependencies": { "express": "^4.0.0" } }
  3. 包装 ToolMessage:将结果与 tool_call_id 关联,证明"这是步骤 1 的执行结果"
  4. 模型观察结果:"看到依赖了,express 版本较旧,建议升级..."
  5. 模型决策下一步:调用 execute_command 执行 npm update

如果缺少 ToolMessage 会怎样?

模型会遗忘自己已经调用了工具,陷入"重复调用同一工具"或"幻觉执行结果"的死循环。


3.6 交互层:命令行对话界面

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

let history = new InMemoryChatMessageHistory();

let systemMessage = new SystemMessage(
    `你是一个经验丰富的程序员,善于使用工具完成任务。

    工具:
    1. read_file: 读取文件
    2. write_file: 写入文件  
    3. execute_command: 执行系统命令,支持指定工作目录
`);

async function interactiveChat() {
    await history.addMessage(systemMessage);

    console.log('\n=== AI 编程助手 ===');
    console.log('输入 "exit" 或 "quit" 退出\n');

    while (true) {
        const userInput = await new Promise((resolve) => {
            rl.question('用户: ', resolve);
        });

        if (userInput.toLowerCase() === 'exit' || userInput.toLowerCase() === 'quit') {
            console.log('\n再见!');
            rl.close();
            break;
        }

        if (userInput.trim() === '') continue;

        try {
            await runAgentWithTools(userInput);
        } catch (error) {
            console.error(`\n❌ 错误: ${error.message}\n`);
        }
    }
}

interactiveChat().catch(console.error);

设计要点:

  • 持久化 SystemMessage:在对话开始时注入,定义 AI 角色和工具说明
  • 循环对话:支持连续多轮交互,历史记录自动累积
  • 优雅退出exit/quit 命令 + Ctrl+C 安全关闭

四、实战演示:完整工作流

以下是一个真实的对话流程,展示三大工具的协同:

场景:创建并运行一个 Express 服务

用户输入:

"帮我创建一个新项目,一个简单的 Express 服务器,监听 3000 端口,返回 这就是我的简易版claude code!!!,然后启动它"

模型执行流程:

graph TD
    A[接收需求] --> B[调用 write_file<br/>首先创建项目目录结构]
    B --> C[调用 execute_command<br/>npm init -y]
    C --> D[调用 execute_command<br/>npm install express]
    D --> E[调用 execute_command<br/>background: true 启动服务]
    E --> F[返回成功信息]

image.png

最终返回:

image.png

PixPin_2026-03-13_15-37-15.gif

五、总结

基于 LangChain.js 完整复刻了 Claude Code 的核心能力闭环。回顾整个实现过程,关键突破点在于三个工具的设计ReAct 循环的实现

模块解决的问题技术亮点
read_file代码上下文感知错误透传,让模型自主决策重试策略
write_file代码自动化落地recursive: true 实现目录链自动创建
execute_command环境交互与验证双模式设计(前台/后台)+ 启动崩溃检测
runAgentWithTools多轮工具调用闭环流式输出 + ToolMessage 反馈机制

从"调用工具"到"编排工作流"

简易版 Claude Code 的真正价值,不在于单个工具的能力,而在于让大模型成为工作流的编排者

用户意图 → 模型决策 → 工具执行 → 结果观察 → 再决策 → ... → 任务完成

这个循环的每一次迭代,都是模型对"当前状态"的重新评估和策略调整。ReAct 范式的引入,让 AI 从"回答问题"进化为"解决问题"。

流式体验的用户价值

代码生成是长耗时任务,流式输出不仅提升感知性能,更重要的是建立信任——用户能实时看到 AI 的思考过程和代码构建进度,而非面对黑盒等待。

当前实现是最小可用版本(MVP),生产级场景需要以下增强:

方向当前局限优化方案
上下文长度大文件读取会撑爆 Token实现 read_file 的分块读取 + 智能摘要
安全管控无命令白名单,可执行任意操作增加危险命令拦截 + 用户确认机制
状态持久化进程退出即丢失对话历史接入 Redis/数据库实现跨会话记忆
多模态扩展仅支持文本交互增加图片输入(UI 设计稿转代码)
协作能力单 Agent 串行执行多 Agent 协作(生成 Agent + 审查 Agent)

写在最后

Claude Code 的爆火证明了 大模型 + 工具调用 + 代码场景 ,这一范式的巨大潜力。通过 LangChain.js,我们无需依赖闭源产品,就能构建完全符合自身工作流的专属助手。

更重要的是,这个过程让我们深入理解了 AI 应用开发的本质:不是替代开发者,而是将重复性、机械性的操作自动化,让人类专注于创造性决策


完整代码已开源GitHub 仓库链接

欢迎在评论区分享你的改造思路和遇到的问题,一起探索 AI 编程助手的更多可能性。