从零构建AI编程助手:基于LangChain Tool Calling的透明化实践
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“内心戏”暴露出来的设计,对于调试和理解其行为至关重要。
那么,它们是如何协同工作的呢?流程可以简化为一个循环:
- 用户在前端输入:“帮我列出当前目录的文件,然后创建一个叫
test.py的空文件。” - 前端将此消息发送给后端。
- 后端将消息和历史对话传给“大脑”(LLM)。
- 大脑分析后,可能输出:“首先,我需要调用
bash_command工具,参数为ls -la。” - 后端解析这个结构化调用,找到对应的
bash_command函数并执行,得到结果total 24 drwxr-xr-x ...。 - 后端将工具执行结果附加到对话历史中,再次送给大脑。
- 大脑看到结果后,进行下一步思考:“文件列表已获取。现在我需要调用
write_file工具,参数为filename: ‘test.py’, content: ‘’。” - 后端再次执行工具,并将创建成功的结果返回给大脑。
- 大脑综合所有信息,生成最终的自然语言回复给用户:“已为您列出当前目录文件,并创建了空的
test.py文件。” - 后端将这个最终回复流式传输回前端展示。
这个“思考 -> 行动 -> 观察结果 -> 再思考”的循环,就是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()]
这里有三个关键点需要注意:
- 参数模型(
args_schema) : 每个工具都必须定义一个继承自pydantic.BaseModel的输入类。Field中的description至关重要,它是AI模型决定是否调用和如何调用该工具的主要依据。描述要清晰、具体。 - 工具描述(
description) : 这是给AI模型看的“工具说明书”。要用自然语言准确描述工具的功能和适用场景。例如,“执行一个Bash shell命令并返回其输出。适用于文件操作、系统信息查询等。” 这比单纯写“运行命令”要好得多。 - 安全警告 : 尤其是
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;
这个前端组件实现了几个关键功能:
- 流式渲染 : 通过
EventSourceAPI监听后端的SSE流,将thought、action、result、final_answer等不同类型的事件实时、分类地展示在UI上。 - 对话历史管理 : 将用户和助手的最终消息作为历史上下文,在下次请求时发送给后端,使AI能记住对话脉络。
- 用户体验优化 : 在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应用开发的路上走得更远、更稳。
更多推荐



所有评论(0)