1. 项目缘起:为什么我们要“手搓”一个AI编程助手?

最近在社区里,关于AI Agent和LangChain的讨论热度一直居高不下。很多开发者朋友都在尝试用现成的框架,比如Dify、LangChain Studio,或者一些开源的Agent项目来搭建自己的AI应用。这当然很方便,但用久了总感觉隔着一层:出了问题不知道根因在哪,想加个定制功能得去啃复杂的源码,最关键的是,对“AI到底是如何思考并执行任务”这个过程,始终有种雾里看花的感觉。

这就像早年学编程,一开始用各种IDE的“一键运行”很爽,但如果不自己动手配一遍环境、写个Makefile、搞清楚编译链接的每一步,总觉得心里不踏实。所以,我决定抛开所有重型框架,回归本质,用最核心的组件——LangChain的Tool Calling机制,配合一个简单的React前端,从零开始构建一个迷你的、功能纯粹的AI编程助手。我把它戏称为“迷你Cursor”,因为它核心要解决的就是类似Cursor编辑器里“Chat to Code”的那个体验:用自然语言告诉AI你的意图,它能理解、拆解,并调用正确的工具(比如执行终端命令、读写文件、搜索网络)来完成任务。

这个项目的价值不在于功能多强大,而在于“透明”和“可教学”。通过亲手实现,你能彻底搞明白几个关键问题:LangChain的Agent执行器(Agent Executor)内部是怎么流转的?Tool Calling的Function Calling格式具体长什么样?大模型(LLM)输出的结构化信息如何被解析并触发真实函数?以及前端如何与这个AI“大脑”进行实时、流畅的交互。理解了这些,无论你是想排查现成框架的诡异bug,还是想设计更复杂的Agent工作流(Workflow),都会拥有十足的底气。

2. 核心架构设计:大脑、手脚与交互界面

在开始写代码之前,我们先要把整个系统的蓝图画清楚。一个能执行任务的AI助手,至少需要三个核心部分,我将其比喻为“大脑”、“手脚”和“控制台”。

大脑(The Brain) : 这就是大语言模型(LLM)本身。它的核心职责是理解用户的自然语言指令,进行逻辑推理,并决定下一步该做什么。在这个项目中,我们选择使用OpenAI的GPT-4或GPT-3.5-Turbo模型,因为它们对Tool Calling(工具调用)的支持非常成熟和稳定。大脑的输出不是一段话,而是一个结构化的决策,比如:“我需要调用‘执行Bash命令’这个工具,参数是 ls -la ”。

手脚(The Hands & Feet) : 这就是Tools(工具)。它们是AI能力延伸到现实世界的桥梁。一个工具本质上就是一个Python函数,附带一份清晰的“说明书”(函数名、描述、参数JSON Schema)。例如,一个“执行Bash命令”的工具,其函数就是封装了 subprocess.run ,说明书则描述它用于运行Shell命令,接受一个名为 command 的字符串参数。AI大脑通过阅读说明书来知道什么时候该调用哪只手。

控制台(The Console) : 这是用户与AI大脑交互的界面。我们用一个轻量级的React前端来实现。它负责收集用户输入,发送给后端的大脑,并实时、流式地展示大脑的“思考过程”(包括它决定调用什么工具、工具执行的结果、以及基于结果的下一步思考)。这种将AI“内心戏”暴露出来的设计,对于调试和理解其行为至关重要。

那么,它们是如何协同工作的呢?流程可以简化为一个循环:

  1. 用户在前端输入:“帮我列出当前目录的文件,然后创建一个叫 test.py 的空文件。”
  2. 前端将此消息发送给后端。
  3. 后端将消息和历史对话传给“大脑”(LLM)。
  4. 大脑分析后,可能输出:“首先,我需要调用 bash_command 工具,参数为 ls -la 。”
  5. 后端解析这个结构化调用,找到对应的 bash_command 函数并执行,得到结果 total 24 drwxr-xr-x ...
  6. 后端将工具执行结果附加到对话历史中,再次送给大脑。
  7. 大脑看到结果后,进行下一步思考:“文件列表已获取。现在我需要调用 write_file 工具,参数为 filename: ‘test.py’, content: ‘’ 。”
  8. 后端再次执行工具,并将创建成功的结果返回给大脑。
  9. 大脑综合所有信息,生成最终的自然语言回复给用户:“已为您列出当前目录文件,并创建了空的 test.py 文件。”
  10. 后端将这个最终回复流式传输回前端展示。

这个“思考 -> 行动 -> 观察结果 -> 再思考”的循环,就是LangChain Agent执行器的核心逻辑。我们的项目,就是要亲手搭建这个循环。

3. 后端核心实现:用LangChain构建AI执行引擎

后端是整个项目的心脏,我们用FastAPI来构建API,用LangChain来组装AI执行引擎。这里我们一步步拆解。

3.1 环境搭建与依赖安装

首先创建一个新的项目目录,并初始化虚拟环境,这是保证依赖纯净的好习惯。

mkdir mini-cursor-agent && cd mini-cursor-agent
python -m venv venv
# Windows: venv\Scripts\activate
# Mac/Linux: source venv/bin/activate

接着,安装核心依赖。这里的选择很有讲究:

  • langchain langchain-openai : LangChain的核心库以及OpenAI的官方集成。注意,我们使用 langchain-openai 而不是旧的 openai 集成包,这是目前官方推荐的方式,能确保用到最新的Tool Calling接口。
  • fastapi uvicorn : 用于构建和运行我们的Web API服务器。
  • python-dotenv : 用于管理环境变量,特别是保护你的OpenAI API Key。
  • pydantic : LangChain内部大量使用Pydantic来定义数据模型,同时我们自己的工具函数参数也会用它来验证。
pip install langchain langchain-openai fastapi uvicorn python-dotenv pydantic

安装完成后,在项目根目录创建 .env 文件,填入你的OpenAI API Key。 切记要将 .env 加入 .gitignore ,不要提交到版本库!

OPENAI_API_KEY=sk-your-actual-api-key-here

3.2 定义AI的“手脚”:创建自定义工具集

工具是AI能力的扩展。我们来创建两个最基础但最实用的工具:执行Bash命令和读写文件。

tools/ 目录下创建 custom_tools.py

import subprocess
import os
from typing import Type
from pydantic import BaseModel, Field
from langchain.tools import BaseTool

# --- 工具1:执行Bash命令 ---
class BashCommandInput(BaseModel):
    """执行Bash命令工具的输入参数模型。"""
    command: str = Field(description="要执行的Bash命令字符串")

class BashCommandTool(BaseTool):
    name: str = "bash_command"
    description: str = "执行一个Bash shell命令并返回其输出。适用于文件操作、系统信息查询等。"
    args_schema: Type[BaseModel] = BashCommandInput

    def _run(self, command: str) -> str:
        """实际执行命令的函数。"""
        try:
            # 安全警告:在生产环境中,此处必须对command做严格的校验和过滤,防止命令注入攻击!
            # 本例为演示,暂不做处理。
            result = subprocess.run(
                command,
                shell=True,
                capture_output=True,
                text=True,
                timeout=30
            )
            if result.returncode == 0:
                return result.stdout
            else:
                return f"Command failed with error: {result.stderr}"
        except subprocess.TimeoutExpired:
            return "Error: Command execution timed out."
        except Exception as e:
            return f"Error executing command: {str(e)}"

    async def _arun(self, command: str) -> str:
        """异步版本,本例中暂不实现。"""
        raise NotImplementedError("Bash command tool does not support async")

# --- 工具2:写文件 ---
class WriteFileInput(BaseModel):
    """写文件工具的输入参数模型。"""
    filename: str = Field(description="要写入的文件路径")
    content: str = Field(description="要写入文件的内容")

class WriteFileTool(BaseTool):
    name: str = "write_file"
    description: str = "将内容写入指定的文件。如果文件已存在,默认会覆盖。"
    args_schema: Type[BaseModel] = WriteFileInput

    def _run(self, filename: str, content: str) -> str:
        try:
            # 简单处理路径,防止目录遍历攻击(简易版)
            filename = os.path.basename(filename)
            with open(filename, 'w', encoding='utf-8') as f:
                f.write(content)
            return f"Successfully wrote to file: {filename}"
        except Exception as e:
            return f"Error writing to file: {str(e)}"

    async def _arun(self, filename: str, content: str) -> str:
        raise NotImplementedError("Write file tool does not support async")

# --- 工具3:读文件(可选,供你扩展) ---
# class ReadFileTool(BaseTool): ...

# 工具集合
def get_custom_tools():
    return [BashCommandTool(), WriteFileTool()]

这里有三个关键点需要注意:

  1. 参数模型( args_schema : 每个工具都必须定义一个继承自 pydantic.BaseModel 的输入类。 Field 中的 description 至关重要,它是AI模型决定是否调用和如何调用该工具的主要依据。描述要清晰、具体。
  2. 工具描述( description : 这是给AI模型看的“工具说明书”。要用自然语言准确描述工具的功能和适用场景。例如,“执行一个Bash shell命令并返回其输出。适用于文件操作、系统信息查询等。” 这比单纯写“运行命令”要好得多。
  3. 安全警告 : 尤其是 BashCommandTool ,直接执行用户输入(经过AI转化)的命令是极度危险的。在演示项目中我们简化了,但在任何严肃的、可联网的项目中,你必须实现白名单机制(只允许 ls , cat , grep 等少数安全命令)或命令过滤,否则就是敞开了一个巨大的安全漏洞。

3.3 组装大脑与执行器:创建LangChain Agent

有了工具,我们需要用LangChain把它们和LLM大脑组装起来,并创建一个执行器(Agent Executor)来驱动整个思考-行动循环。

agent/ 目录下创建 agent_executor.py

import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain.agents import create_openai_tools_agent, AgentExecutor
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.tools.render import format_tool_to_openai_tool
from tools.custom_tools import get_custom_tools

# 加载环境变量
load_dotenv()

def create_agent_executor():
    """
    创建并配置一个完整的OpenAI Tools Agent执行器。
    返回: AgentExecutor实例
    """
    # 1. 初始化LLM(大脑)
    # 使用gpt-3.5-turbo-1106或gpt-4-turbo-preview,它们对tool calling支持良好
    llm = ChatOpenAI(
        model="gpt-3.5-turbo-1106", # 或 "gpt-4-turbo-preview"
        temperature=0, # 对于执行任务,低温度(更确定性)通常更好
        openai_api_key=os.getenv("OPENAI_API_KEY")
    )

    # 2. 获取工具列表,并转换为OpenAI Tool格式
    tools = get_custom_tools()
    # 这个format_tool_to_openai_tool函数是关键,它将LangChain Tool对象
    # 转换成OpenAI API能识别的function calling格式。
    openai_tools = [format_tool_to_openai_tool(tool) for tool in tools]

    # 3. 构建Prompt模板
    # Prompt是引导AI行为的关键。我们定义一个包含系统指令、对话历史和用户输入的模板。
    prompt = ChatPromptTemplate.from_messages([
        ("system", """你是一个高效的编程助手,可以执行Bash命令和文件操作。
        你的目标是根据用户请求,规划并执行一系列工具调用,最终完成任务。
        请逐步思考,每次只调用一个最必要的工具。
        工具执行结果会以`Observation:`为前缀返回给你。基于观察结果决定下一步。
        当你拥有足够信息回答用户问题时,请直接给出最终答案,不要调用工具。
        你的回答应简洁专业。
        """),
        MessagesPlaceholder(variable_name="chat_history"), # 预留位置存放历史消息
        ("user", "{input}"), # 用户当前输入
        MessagesPlaceholder(variable_name="agent_scratchpad"), # 关键!用于存放Agent的思考、工具调用和观察的历史
    ])

    # 4. 创建Agent
    # `create_openai_tools_agent` 是LangChain提供的一个高级函数,它帮我们处理了
    # 将LLM、Prompt、Tools绑定在一起的复杂逻辑,返回一个可运行的Agent对象。
    agent = create_openai_tools_agent(
        llm=llm,
        tools=tools, # 注意这里传入的是原始的LangChain Tool对象
        prompt=prompt
    )

    # 5. 创建Agent执行器(Executor)
    # 这是驱动整个循环的引擎。它负责运行Agent,解析其输出,调用工具,并将结果反馈回去。
    agent_executor = AgentExecutor(
        agent=agent,
        tools=tools,
        verbose=True, # 设为True会在控制台打印详细的执行日志,调试时极其有用
        handle_parsing_errors=True, # 当模型输出无法解析为工具调用时,尝试让模型重试或处理错误
        max_iterations=10, # 安全限制,防止AI陷入死循环
        early_stopping_method="generate", # 当达到最大迭代次数时,让模型生成一个最终回复
    )

    return agent_executor

这段代码有几个需要深入理解的地方:

  • format_tool_to_openai_tool : 这是连接LangChain Tools和OpenAI Function Calling的桥梁。它把我们的工具对象(包含名字、描述、参数schema)转换成OpenAI API要求的特定JSON格式。没有这一步,模型就无法识别我们的工具。
  • MessagesPlaceholder(variable_name="agent_scratchpad") : 这是Agent工作流中的“草稿纸”。LangChain执行器会自动将Agent的“思考”(我要调用工具X)、“行动”(工具调用)和“观察”(工具返回结果)以特定的格式追加到这里,供模型在下一次推理时参考。这是实现多步任务的核心机制。
  • AgentExecutor 参数
    • verbose=True : 强烈建议在开发时开启。你会在终端看到类似 > Entering new AgentExecutor chain... Thought: I need to... Action: bash_command Action Input: {"command": "ls -la"} Observation: ... 的日志,这是理解Agent内部状态的最佳途径。
    • handle_parsing_errors=True : 模型有时可能输出不符合规范的JSON。这个选项让执行器能捕获这类错误,并尝试让模型修正或直接生成回复,提升鲁棒性。
    • max_iterations 必须设置 。这是防止AI陷入“思考-调用”无限循环的安全阀。根据任务复杂度设置,一般5-10次足够。

3.4 构建API接口:连接前端与Agent

最后,我们用FastAPI创建一个Web服务,提供聊天接口。为了获得类似ChatGPT的流式体验,我们将使用Server-Sent Events (SSE)。

在项目根目录创建 main.py

from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import AsyncGenerator, List, Dict, Any
import asyncio
import json

from agent.agent_executor import create_agent_executor

app = FastAPI(title="Mini Cursor Agent API")

# 允许前端跨域请求
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 生产环境应替换为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 全局Agent执行器(简单起见,启动时创建)
agent_executor = None

@app.on_event("startup")
async def startup_event():
    global agent_executor
    print("Initializing Agent Executor...")
    agent_executor = create_agent_executor()
    print("Agent Executor ready.")

# 请求和响应模型
class ChatRequest(BaseModel):
    message: str
    chat_history: List[Dict[str, Any]] = [] # 格式:[{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]

class StreamChunk(BaseModel):
    type: str  # 例如: "thought", "action", "result", "final_answer"
    content: str

@app.post("/chat")
async def chat_stream(request: ChatRequest):
    """处理聊天请求,并以流式方式返回Agent的思考过程。"""
    global agent_executor
    if not agent_executor:
        raise HTTPException(status_code=503, detail="Agent not initialized")

    async def event_generator() -> AsyncGenerator[str, None]:
        """生成SSE事件的异步生成器。"""
        # 将对话历史转换为LangChain期望的格式
        from langchain.schema import HumanMessage, AIMessage
        formatted_history = []
        for msg in request.chat_history:
            if msg["role"] == "user":
                formatted_history.append(HumanMessage(content=msg["content"]))
            elif msg["role"] == "assistant":
                formatted_history.append(AIMessage(content=msg["content"]))

        # 关键:使用Agent执行器的astream_events方法(LangChain新API)
        # 这允许我们实时捕获执行过程中的各种事件。
        try:
            events = agent_executor.astream_events(
                {"input": request.message, "chat_history": formatted_history},
                version="v2" # 使用v2版本的事件流
            )
            async for event in events:
                event_name = event.get("event")
                # 1. 发送Agent的“思考”
                if event_name == "on_chat_model_stream":
                    content = event["data"]["chunk"].content
                    if content: # 过滤空内容
                        chunk = StreamChunk(type="thought", content=content)
                        yield f"data: {chunk.json()}\n\n"
                # 2. 发送工具“调用”动作
                elif event_name == "on_tool_start":
                    tool_name = event["name"]
                    tool_input = event["data"].get("input", {})
                    chunk = StreamChunk(type="action", content=f"调用工具 `{tool_name}`, 参数: {tool_input}")
                    yield f"data: {chunk.json()}\n\n"
                # 3. 发送工具“执行结果”
                elif event_name == "on_tool_end":
                    output = event["data"].get("output", "")
                    chunk = StreamChunk(type="result", content=f"工具执行结果: {output}")
                    yield f"data: {chunk.json()}\n\n"
                # 4. 发送最终答案
                elif event_name == "on_chain_end" and event.get("name") == "AgentExecutor":
                    output = event["data"].get("output", {}).get("output", "")
                    if output:
                        chunk = StreamChunk(type="final_answer", content=output)
                        yield f"data: {chunk.json()}\n\n"
        except Exception as e:
            error_chunk = StreamChunk(type="error", content=f"Agent执行出错: {str(e)}")
            yield f"data: {error_chunk.json()}\n\n"
        finally:
            yield "event: close\ndata: {}\n\n" # 发送关闭事件

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no" # 针对Nginx代理的配置
        }
    )

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

这个API端点的核心是 astream_events 方法。这是LangChain提供的一个强大功能,它允许我们像监听事件一样,实时获取Agent执行链中每个环节的输出。我们将这些事件分类(思考、行动、结果、最终答案),通过SSE推送给前端,从而实现打字机效果和完整的“思考过程”展示。

注意 astream_events 是较新的API,确保你的LangChain版本足够新(>=0.1.0)。如果遇到兼容性问题,也可以使用传统的 astream_log 方法,但事件分类可能没那么精细。

至此,一个功能完整的AI Agent后端就搭建完成了。运行 python main.py ,你的AI大脑就在 http://localhost:8000 开始服务了。

4. 前端交互界面:用React构建实时对话控制台

后端准备好了,我们需要一个界面来和它对话。我们将使用React和Vite快速搭建一个简洁的控制台。这里我们聚焦于核心功能:发送消息、流式接收并渲染AI的思考过程。

4.1 项目初始化与依赖安装

使用Vite快速创建一个React + TypeScript项目(TypeScript能更好地管理从后端传来的复杂事件流数据)。

npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
npm install axios  # 用于HTTP请求
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material  # 可选,用于快速构建UI

4.2 核心聊天组件实现

我们创建一个主要的聊天组件 ChatConsole.tsx 。它的核心状态包括消息列表、当前输入,以及一个用于管理SSE连接的引用。

// src/components/ChatConsole.tsx
import React, { useState, useRef, useEffect } from 'react';
import { TextField, Button, Box, Paper, Typography, List, ListItem, ListItemText, Chip, LinearProgress, Alert } from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import axios from 'axios';

// 定义消息类型
interface Message {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  timestamp: Date;
}

// 定义从后端SSE接收的数据块类型
interface StreamChunk {
  type: 'thought' | 'action' | 'result' | 'final_answer' | 'error';
  content: string;
}

const ChatConsole: React.FC = () => {
  // 状态管理
  const [messages, setMessages] = useState<Message[]>([
    { id: '1', role: 'assistant', content: '你好!我是你的迷你编程助手。我可以执行Bash命令和文件操作。请告诉我你需要什么帮助?', timestamp: new Date() }
  ]);
  const [input, setInput] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [currentThinking, setCurrentThinking] = useState<string>(''); // 用于实时显示AI“思考”

  const messagesEndRef = useRef<HTMLDivElement>(null);
  const eventSourceRef = useRef<EventSource | null>(null);

  // 滚动到最新消息
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, currentThinking]);

  // 组件卸载时关闭SSE连接
  useEffect(() => {
    return () => {
      if (eventSourceRef.current) {
        eventSourceRef.current.close();
      }
    };
  }, []);

  // 发送消息到后端
  const sendMessage = async () => {
    if (!input.trim() || isLoading) return;

    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content: input,
      timestamp: new Date()
    };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setIsLoading(true);
    setError(null);
    setCurrentThinking(''); // 清空上一轮的思考

    // 准备对话历史(只包含user和assistant的最终消息)
    const chatHistory = messages
      .filter(m => m.role === 'user' || m.role === 'assistant')
      .map(m => ({ role: m.role, content: m.content }));

    // 关闭可能存在的旧连接
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
    }

    try {
      // 使用EventSource连接SSE端点
      const eventSource = new EventSource(`http://localhost:8000/chat?message=${encodeURIComponent(input)}&chat_history=${encodeURIComponent(JSON.stringify(chatHistory))}`);
      eventSourceRef.current = eventSource;

      let accumulatedFinalAnswer = '';

      eventSource.onmessage = (event) => {
        if (event.data.trim() === '') return;
        
        try {
          const chunk: StreamChunk = JSON.parse(event.data);
          
          switch (chunk.type) {
            case 'thought':
              // 实时更新思考内容
              setCurrentThinking(prev => prev + chunk.content);
              break;
            case 'action':
              // 将工具调用作为一个系统消息展示
              setMessages(prev => [...prev, {
                id: `action_${Date.now()}`,
                role: 'system',
                content: `🛠️ ${chunk.content}`,
                timestamp: new Date()
              }]);
              setCurrentThinking(''); // 思考结束,开始行动
              break;
            case 'result':
              // 将工具结果作为系统消息展示
              setMessages(prev => [...prev, {
                id: `result_${Date.now()}`,
                role: 'system',
                content: `📊 ${chunk.content}`,
                timestamp: new Date()
              }]);
              break;
            case 'final_answer':
              // 累积最终答案(可能分多个chunk)
              accumulatedFinalAnswer += chunk.content;
              // 可以在这里立即更新,也可以等流结束。我们选择立即更新以获得打字机效果。
              setMessages(prev => {
                const lastMsg = prev[prev.length - 1];
                if (lastMsg.role === 'assistant' && lastMsg.id === 'streaming-assistant') {
                  // 更新正在流式输出的消息
                  return [...prev.slice(0, -1), { ...lastMsg, content: accumulatedFinalAnswer }];
                } else {
                  // 创建新的助手消息
                  return [...prev, {
                    id: 'streaming-assistant',
                    role: 'assistant',
                    content: accumulatedFinalAnswer,
                    timestamp: new Date()
                  }];
                }
              });
              break;
            case 'error':
              setError(chunk.content);
              eventSource.close();
              break;
          }
        } catch (e) {
          console.error('Failed to parse SSE chunk:', e, event.data);
        }
      };

      eventSource.onerror = (err) => {
        console.error('EventSource failed:', err);
        setError('与AI助手的连接发生错误。');
        eventSource.close();
        setIsLoading(false);
      };

      // 监听自定义的close事件(由后端发送)
      eventSource.addEventListener('close', () => {
        eventSource.close();
        setIsLoading(false);
        setCurrentThinking('');
        // 给流式消息一个固定的ID
        setMessages(prev => prev.map(msg => 
          msg.id === 'streaming-assistant' ? { ...msg, id: Date.now().toString() } : msg
        ));
      });

    } catch (err) {
      console.error('Failed to start SSE connection:', err);
      setError('无法连接到AI助手服务。请确保后端正在运行。');
      setIsLoading(false);
    }
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      sendMessage();
    }
  };

  return (
    <Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column', p: 2 }}>
      <Typography variant="h4" gutterBottom>
        迷你Cursor AI编程助手
      </Typography>
      <Typography variant="body2" color="text.secondary" gutterBottom>
        尝试输入:“列出当前目录文件” 或 “创建一个名为hello.py的文件,内容打印Hello World”
      </Typography>

      {error && (
        <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
          {error}
        </Alert>
      )}

      {/* 消息列表区域 */}
      <Paper elevation={3} sx={{ flexGrow: 1, overflow: 'auto', p: 2, mb: 2 }}>
        <List>
          {messages.map((msg) => (
            <ListItem key={msg.id} alignItems="flex-start" sx={{ 
              flexDirection: 'column',
              alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
              bgcolor: msg.role === 'system' ? 'action.hover' : 'transparent',
              borderRadius: 1,
              mb: 1
            }}>
              <Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
                <Chip 
                  label={msg.role === 'user' ? '你' : msg.role === 'assistant' ? '助手' : '系统'} 
                  size="small" 
                  color={msg.role === 'user' ? 'primary' : msg.role === 'assistant' ? 'success' : 'default'}
                  variant="outlined"
                />
                <Typography variant="caption" sx={{ ml: 1, color: 'text.secondary' }}>
                  {msg.timestamp.toLocaleTimeString()}
                </Typography>
              </Box>
              <ListItemText 
                primary={
                  <Typography 
                    component="pre" 
                    sx={{ 
                      whiteSpace: 'pre-wrap', 
                      wordBreak: 'break-word',
                      fontFamily: 'monospace',
                      fontSize: '0.9rem',
                      bgcolor: msg.role === 'user' ? 'primary.light' : (msg.role === 'system' ? 'grey.100' : 'transparent'),
                      p: msg.role !== 'assistant' ? 1 : 0,
                      borderRadius: 1,
                      border: msg.role === 'system' ? '1px dashed' : 'none',
                      borderColor: 'divider'
                    }}
                  >
                    {msg.content}
                  </Typography>
                } 
              />
            </ListItem>
          ))}
          {/* 实时显示AI思考过程 */}
          {currentThinking && (
            <ListItem sx={{ bgcolor: 'info.light', borderRadius: 1, mb: 1 }}>
              <ListItemText 
                primary={
                  <Box sx={{ display: 'flex', alignItems: 'center' }}>
                    <Typography variant="body2" sx={{ mr: 1 }}>🤔 AI正在思考:</Typography>
                    <Typography component="span" variant="body2" fontFamily="monospace">
                      {currentThinking}
                    </Typography>
                  </Box>
                } 
              />
            </ListItem>
          )}
          <div ref={messagesEndRef} />
        </List>
      </Paper>

      {isLoading && <LinearProgress sx={{ mb: 2 }} />}

      {/* 输入区域 */}
      <Box sx={{ display: 'flex' }}>
        <TextField
          fullWidth
          variant="outlined"
          placeholder="输入你的指令... (例如:ls -la, 然后创建一个test.txt文件)"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyPress={handleKeyPress}
          disabled={isLoading}
          multiline
          maxRows={4}
        />
        <Button
          variant="contained"
          endIcon={<SendIcon />}
          onClick={sendMessage}
          disabled={isLoading || !input.trim()}
          sx={{ ml: 2 }}
        >
          发送
        </Button>
      </Box>
    </Box>
  );
};

export default ChatConsole;

这个前端组件实现了几个关键功能:

  1. 流式渲染 : 通过 EventSource API监听后端的SSE流,将 thought action result final_answer 等不同类型的事件实时、分类地展示在UI上。
  2. 对话历史管理 : 将用户和助手的最终消息作为历史上下文,在下次请求时发送给后端,使AI能记住对话脉络。
  3. 用户体验优化 : 在AI“思考”时,在输入框上方实时显示其“内心独白”;将工具调用和结果以特殊的系统消息样式展示,使整个执行过程一目了然。

4.3 集成与运行

ChatConsole 组件挂载到 App.tsx ,并运行开发服务器。

// src/App.tsx
import ChatConsole from './components/ChatConsole';
import { CssBaseline, Container } from '@mui/material';

function App() {
  return (
    <>
      <CssBaseline />
      <Container maxWidth="lg" sx={{ mt: 4 }}>
        <ChatConsole />
      </Container>
    </>
  );
}

export default App;

分别启动后端和前端的开发服务器:

# 终端1:启动后端
cd /path/to/mini-cursor-agent
python main.py

# 终端2:启动前端
cd /path/to/mini-cursor-agent/frontend
npm run dev

现在,打开浏览器访问 http://localhost:5173 (Vite默认端口),你就可以和你的“迷你Cursor”对话了。试着输入“帮我看看当前目录有什么文件,然后创建一个 hello.py ,内容写上 print(‘Hello from AI Agent!’) ”,你会看到它一步步思考、调用工具、并最终完成任务的完整过程。

5. 避坑指南与实战经验分享

从零搭建这样一个项目,我踩过不少坑,也总结出一些让Agent更稳定、更聪明的经验。这些在官方文档里往往不会细说。

5.1 Tool Calling的稳定性:描述、格式与模型选择

Tool Calling的稳定性很大程度上取决于你如何“描述”你的工具,以及选择哪个模型。

1. 工具描述(Description)是灵魂 描述不能太短或太模糊。对比一下:

  • 差的描述 “执行命令” 。模型可能不知道这是什么命令,什么时候该用它。
  • 好的描述 “执行一个Bash shell命令并返回其输出。适用于在服务器上进行文件列表、查看文件内容、搜索文本、管理进程等操作。对于危险操作(如rm -rf)需用户明确确认。” 后者的描述清晰界定了工具的用途、适用场景甚至安全边界,能极大提高模型调用的准确率。

2. 参数Schema要严谨 使用Pydantic的 Field 时,除了 description ,还可以利用 examples 参数提供调用示例。

command: str = Field(description="要执行的Bash命令", examples=["ls -la", "find . -name '*.py'", "grep -r 'import' ."])

这能给模型更明确的输入格式指引。虽然OpenAI的模型不一定100%遵循 examples ,但这是一个好习惯。

3. 模型选择至关重要 不是所有模型都擅长Tool Calling。经过实测:

  • gpt-3.5-turbo-1106 及以上版本 : 对Tool Calling支持很好,性价比高,是入门首选。
  • gpt-4-turbo-preview ( gpt-4-0125-preview 等) : 在复杂任务规划、工具选择顺序上表现显著优于3.5,更不容易“犯傻”,但成本也高。
  • gpt-4o : 在速度、成本和能力上取得了很好的平衡,对Tool Calling的支持也非常出色,是目前综合体验最好的选择之一。
  • 避免使用旧版本 : 如 gpt-3.5-turbo-0613 之前的版本,对Function Calling/Tool Calling支持不完善,容易输出格式错误。

5.2 安全是重中之重:给AI的“手脚”戴上镣铐

让AI直接执行Bash命令或文件操作,无异于赋予它服务器上的执行权限。必须实施严格的安全策略。

1. 命令白名单机制 这是最有效的方法。不要直接拼接用户输入,而是定义一个允许的命令列表。

ALLOWED_COMMANDS = {
    'list_files': {'cmd': 'ls -la', 'desc': '列出当前目录详细内容'},
    'find_py_files': {'cmd': "find . -name '*.py' -type f", 'desc': '查找所有Python文件'},
    'search_in_files': {'cmd_template': "grep -r '{pattern}' .", 'desc': '在文件中搜索文本'}, # 使用模板,对参数进行转义
}

在工具函数中,先解析AI想调用的命令意图,然后映射到白名单中的安全命令。这极大地限制了攻击面。

2. 输入验证与转义 如果必须支持动态命令,则必须对输入进行严格的验证和转义。

  • 验证 : 使用正则表达式检查命令是否只包含允许的字符(字母、数字、空格、少数安全符号)。
  • 转义 : 使用 shlex.quote() 对参数进行转义,防止注入。
  • 沙箱 : 考虑在Docker容器或高度受限的系统用户环境中运行AI代理进程。

3. 文件路径限制 对于文件读写工具,一定要将操作限制在某个工作目录内,并使用 os.path.abspath os.path.commonprefix 检查,防止路径遍历攻击(如 ../../../etc/passwd )。

5.3 调试技巧:读懂Agent的“内心戏”

当Agent行为不符合预期时, verbose=True 的输出是你的第一手资料。你需要学会解读这个执行链日志。

一段典型的日志如下:

> Entering new AgentExecutor chain...
Thought: 用户想列出文件。我应该使用bash_command工具。
Action: bash_command
Action Input: {"command": "ls -la"}
Observation: total 24 drwxr-xr-x ... (这里是命令输出)
Thought: 我已经列出了文件。用户没有其他要求,我可以给出最终答案了。
Final Answer: 当前目录的文件列表如下: [这里列出文件]
> Finished chain.
  • Thought : 代表模型的“思考”。如果这里逻辑混乱,可能是Prompt没写好,或者任务太复杂模型无法理解。
  • Action Action Input : 模型决定调用的工具和参数。如果工具名错误或参数格式不对,检查工具的 name args_schema 定义是否与模型输出匹配。常见的错误是模型输出了JSON,但键名与Schema定义不符。
  • Observation : 工具执行后的返回结果。如果结果是 Error: ... ,那么问题出在你的工具函数内部(如权限不足、命令不存在)。如果结果是成功的,但模型在下一步思考中误解了它,那可能是结果太冗长或格式不清晰,需要在工具返回前对结果进行清洗和总结。

高级调试 : 你还可以在 AgentExecutor 中设置 return_intermediate_steps=True ,然后在API中将这些中间步骤返回给前端展示,这比看控制台日志更直观。

5.4 性能与成本优化

1. 管理对话历史长度 每次请求都将完整的对话历史发送给模型,这会消耗大量Token(尤其是长对话)。解决方案:

  • 总结历史 : 当历史消息超过一定长度(如10轮)后,调用一次模型,让它用一段话总结之前的对话要点,然后用这个总结替换掉旧的历史消息。
  • 向量化检索 : 对于非常长的对话(如代码库分析),可以将历史消息存入向量数据库,每次只检索与当前问题最相关的几条历史。这就是RAG(检索增强生成)在对话中的应用。

2. 设置超时与重试 网络请求和工具执行都可能超时。务必为你的HTTP客户端(调用OpenAI API)和工具执行(如 subprocess.run )设置合理的超时时间,并实现重试逻辑(对于可重试的错误,如网络抖动)。

3. 缓存工具结果 对于一些耗时长、结果不变的工具调用(如获取系统信息、查询静态文档),可以引入缓存机制(如 functools.lru_cache ),避免AI在同一个会话中重复执行相同操作,节省时间和成本。

6. 从“玩具”到“工具”:下一步进阶方向

完成这个基础版本后,你的“迷你Cursor”已经具备了核心能力。但要让它在真实场景中发挥作用,还需要考虑以下几个进阶方向:

1. 工具生态扩展 目前只有两个基础工具。一个强大的编程助手需要更多“专业技能”:

  • 代码理解与生成 : 集成类似 ast 解析、调用代码分析库(如 tree-sitter )的工具,让AI能理解项目结构、函数签名。
  • Git操作 : 封装 git status , git diff , git add , git commit 等命令,让AI协助版本管理。
  • 网络搜索 : 集成DuckDuckGo或Serper API,让AI能获取最新信息。
  • 包管理 : 封装 pip install npm install 等,让AI能管理项目依赖。 关键在于,每增加一个工具,都要像之前一样,精心编写它的描述和参数Schema。

2. 引入规划与反思能力 当前的Agent是“反应式”的,走一步看一步。对于复杂任务(如“重构这个模块”),它可能缺乏整体规划。可以引入更高级的框架思路:

  • Plan-and-Execute模式 : 先让一个“规划师”LLM拆解任务,生成一个步骤列表(如:1. 分析现有代码结构;2. 识别重复逻辑;3. 提取公共函数...),再由一个“执行者”Agent按步骤调用工具。这可以用 langgraph 来编排。
  • ReAct(Reason + Act)模式强化 : 在Prompt中更强调让模型在每一步都进行“批判性反思”,比如“上一步的结果是否达到了预期?如果没有,问题出在哪里?”这能减少错误累积。

3. 前端体验深化

  • 代码高亮与渲染 : 对AI返回的代码块,使用如 react-syntax-highlighter 进行高亮显示。
  • 交互式工具调用 : 对于某些危险操作(如删除文件、运行未知脚本),在前端弹出确认框,让用户批准后再执行,实现“人机协同”。
  • 会话管理 : 增加保存会话、加载历史会话、为会话命名的功能。

4. 部署与监控

  • 容器化 : 使用Docker将前后端打包,便于在任何环境部署。
  • API密钥管理 : 使用像 Vault 或云服务商提供的密钥管理服务,而不是写在环境文件里。
  • 监控与日志 : 记录每一次工具调用、模型请求,便于分析使用情况、排查问题和优化成本。

通过这个从零搭建的过程,你收获的不仅仅是一个可运行的AI编程助手Demo,更重要的是彻底理解了AI Agent是如何被“组装”起来的。下次当你再使用Cursor、Copilot,或者遇到LangChain、LangGraph的复杂项目时,你看到的将不再是一个黑盒,而是一个由大脑、工具、执行循环清晰构成的系统。这份理解,能让你在AI应用开发的路上走得更远、更稳。

更多推荐