从零构建AI编程助手:System Prompt、Function Calling与ReAct循环实战
1. 项目概述:从“哑巴模型”到“会说话的智能体”
最近在折腾AI编程助手,发现一个挺有意思的现象:很多开发者把大语言模型(LLM)接进VSCode后,它要么像个“复读机”一样只输出代码片段,要么就是交互逻辑混乱,完全不像一个能理解上下文、能主动思考的“编程伙伴”。这其实就是典型的“地基”没打牢。我们拿到了一个强大的模型引擎(比如Claude、DeepSeek),但如果没有一套好的“对话系统”和“交互协议”来驱动它,它就无法真正“开口说话”,更别提进行复杂的代码生成和问题解决了。
“让模型开口说话”,听起来有点玄乎,其实核心就是构建一个能让LLM理解开发者意图、遵循特定格式进行思考、并能调用工具执行动作的“智能体(Agent)”框架。这不仅仅是调用一个API返回文本那么简单。你需要处理系统指令(System Prompt)的设定、工具(Function)的描述与调用、以及让模型进行“思考-行动-观察”循环(ReAct范式)的机制。网上很多教程只教你怎么把API Key填进去,但没告诉你为什么模型不按你的想法来,或者为什么总是报一些莫名其妙的 400 或 429 错误。
这篇文章,我们就来彻底拆解这个“地基”。我会以一个零基础的视角,带你从零搭建一个能让Claude Code(或任何类似智能体)真正“活”起来的后端服务。我们会聚焦于三个最核心的模块: System Prompt工程 、 Function Calling实现 、以及 ReAct智能体循环 。过程中,我会穿插大量我踩过的坑和调试心得,比如如何处理 context length 超限、如何设计稳定的工具调用流程、以及如何应对各种API错误。目标不是复现一个玩具,而是构建一个健壮、可扩展、能真正用于开发实战的智能体核心。
2. 核心模块一:System Prompt——定义模型的“人格”与“职责”
很多人把System Prompt简单理解为“系统提示词”,随便写两句“你是一个有帮助的AI助手”就完事了。对于编程智能体来说,这是大错特错的。System Prompt是模型的“宪法”和“岗位说明书”,它定义了模型的角色、行为边界、思考框架和输出格式。一个模糊的System Prompt会导致模型行为不可预测,输出格式混乱,工具调用失败。
2.1 System Prompt的核心构成
一个针对代码生成与问题解决的System Prompt,应该包含以下几个层次:
-
角色与目标定义 :明确告诉模型“你是谁”和“你要干什么”。这比“有帮助的助手”具体得多。
你是一个专业的软件开发助手,集成在IDE中。你的主要目标是理解用户提出的编程问题、代码需求或调试请求,并生成准确、高效、可运行的代码解决方案。你应当优先考虑代码的正确性、可读性和最佳实践。 -
上下文与约束声明 :这是避免
400 Bad Request(特别是context length超限)和模型“胡言乱语”的关键。你需要明确模型的“工作环境”和“能力边界”。当前对话发生在集成开发环境(IDE)中。你无法直接访问互联网、执行命令行或读写用户本地文件(除非通过我提供的特定工具)。你生成的所有代码都应当是基于当前提供的文件上下文和问题描述。 重要约束:你**必须**严格遵守以下输出格式规范。任何偏离格式的回应都将导致系统错误。 -
思考过程与输出格式规范 :这是引导模型进行结构化思考(ReAct)和标准化输出的核心。你必须用极其清晰、无歧义的语言描述模型应该如何一步一步推理,以及最终输出的样子。
你的思考与回应必须严格遵循以下结构: 【思考】 (在此处进行你的内部推理。分析用户的问题,评估需要哪些信息,计划解决步骤。这是只给你自己看的,不需要包含代码或最终答案。) 【行动】 (如果你判断需要调用工具来获取信息(如读取文件、搜索知识),或需要执行某个操作,请在此处声明。格式必须是:`<action>工具名称</action>`,并在后续提供参数。如果不需要,则写“无”。) 【最终答案】 (将你的最终解决方案放在这里。如果是代码,请用正确的语法高亮标记代码块(如```python)。同时提供必要的解释。)这个结构强制模型将“思考”(内部推理)、“行动”(工具调用)和“输出”(最终答案)分离,是构建可靠智能体的基石。
2.2 实操:编写与注入System Prompt
在实际调用API时,如何传递这个Prompt取决于模型提供商。以OpenAI的Chat Completion API为例,通常通过 messages 列表中的第一个 system 角色消息传入。
import openai
client = openai.OpenAI(api_key="your-api-key")
system_prompt = """(这里放入上面编写的完整、详细的System Prompt)"""
def chat_with_model(user_query, conversation_history=[]):
messages = [
{"role": "system", "content": system_prompt},
*conversation_history, # 历史对话上下文
{"role": "user", "content": user_query}
]
try:
response = client.chat.completions.create(
model="gpt-4", # 或 claude-3-5-sonnet 等,需适配对应API
messages=messages,
temperature=0.1, # 对于代码生成,低温度值更稳定
stream=True # 推荐使用流式输出,体验更好
)
# 处理流式响应...
except openai.BadRequestError as e:
# 重点处理400错误,特别是context length超限
if "maximum context length" in str(e):
print(f"错误:上下文长度超限!当前token数估计已超过模型上限。")
# 处理策略:清空早期历史或总结历史
return handle_context_overflow(conversation_history, user_query)
else:
raise e
关键注意事项 :
- 长度管理 :一个详细的System Prompt可能占用1000+个token。你需要将其计入整个对话的上下文窗口(例如GPT-4的128K,Claude 200K)。如果加上长对话历史,很容易触发
400错误,提示maximum context length is ... tokens。 解决方案 是实现一个“对话历史摘要”或“滑动窗口”机制,只保留最近N轮对话或最重要的信息。 - 格式稳定性 :模型有时会“忘记”或“偏离”你设定的输出格式。除了在System Prompt中强调,还可以在每次用户提问后,在
user消息里轻轻提醒,例如:“请严格按照要求的【思考】、【行动】、【最终答案】格式回应。” - 不同模型的差异 :Anthropic的Claude模型对System Prompt的处理方式可能与OpenAI不同(例如,可能有专门的
system参数)。DeepSeek、通义千问等国内模型API的参数也可能有差异。务必查阅对应模型的最新API文档。
3. 核心模块二:Function Calling——赋予模型“手”和“眼”
模型再聪明,如果只能空想,那也只是一个知识库。Function Calling(函数调用)就是模型的“手”和“眼”,让它能读取文件、执行命令、搜索网络、调用其他API,从而与现实世界交互。这是Claude Code这类智能体能够“理解”项目上下文(如读取当前打开的文件)并“操作”项目(如创建新文件)的技术基础。
3.1 如何定义“工具”(Functions)
你需要以结构化的方式向模型描述它可以使用哪些工具。这通常是一个JSON Schema列表,每个工具包含名称、描述和参数定义。
# 定义可供模型调用的工具列表
available_functions = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取指定路径文件的内容。用于理解现有代码上下文。",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "要读取的文件的绝对路径或相对于项目根目录的路径。"
}
},
"required": ["file_path"],
"additionalProperties": False
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "在指定路径创建或覆盖一个文件。用于生成新的代码文件或修改现有文件。",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "要写入的文件的路径。"
},
"content": {
"type": "string",
"description": "要写入文件的内容。"
}
},
"required": ["file_path", "content"],
"additionalProperties": False
}
}
},
# 可以添加更多工具,如 execute_command, search_web, query_database 等
]
定义工具的黄金法则 :
- 描述清晰准确 :
description字段要明确工具的目的和使用场景,这直接决定模型是否会正确调用它。 - 参数定义严谨 :
parameters的Schema要完整定义每个字段的类型、描述、是否必需。设置additionalProperties: False可以防止模型传入未定义的参数。 - 工具粒度适中 :工具既不能太粗(如
do_everything),也不能太细(如add_line_to_file)。read_file和write_file是两个非常好的基础工具。
3.2 实现工具调用与响应处理
当模型在【行动】部分声明要调用工具时,你的后端需要解析这个声明,找到对应的本地函数执行,并将结果以特定格式反馈给模型,让模型继续思考。
import json
import subprocess
import os
# 1. 本地实现工具对应的真实函数
def read_file(file_path):
"""对应 read_file 工具的真实实现"""
try:
# 安全校验:防止路径遍历攻击
base_dir = os.getcwd()
requested_path = os.path.normpath(os.path.join(base_dir, file_path))
if not requested_path.startswith(base_dir):
return {"error": "Access denied: Path traversal attempt detected."}
with open(requested_path, 'r', encoding='utf-8') as f:
content = f.read()
return {"success": True, "content": content}
except FileNotFoundError:
return {"error": f"File not found: {file_path}"}
except Exception as e:
return {"error": f"Failed to read file: {str(e)}"}
def write_file(file_path, content):
"""对应 write_file 工具的真实实现"""
try:
base_dir = os.getcwd()
requested_path = os.path.normpath(os.path.join(base_dir, file_path))
if not requested_path.startswith(base_dir):
return {"error": "Access denied: Path traversal attempt detected."}
os.makedirs(os.path.dirname(requested_path), exist_ok=True)
with open(requested_path, 'w', encoding='utf-8') as f:
f.write(content)
return {"success": True, "message": f"File '{file_path}' written successfully."}
except Exception as e:
return {"error": f"Failed to write file: {str(e)}"}
# 工具名称到实现函数的映射
TOOL_HANDLERS = {
"read_file": read_file,
"write_file": write_file,
}
# 2. 解析模型响应,执行工具调用
def parse_and_execute_tool_call(model_response_content):
"""
从模型的文本响应中解析出工具调用指令并执行。
假设模型响应格式为: <action>write_file</action> {"file_path": "test.py", "content": "print('hello')"}
"""
lines = model_response_content.strip().split('\n')
tool_name = None
tool_args = None
for line in lines:
if line.startswith('<action>') and line.endswith('</action>'):
tool_name = line[8:-9].strip() # 提取工具名
elif line.startswith('{'):
try:
tool_args = json.loads(line)
except json.JSONDecodeError:
pass
if tool_name and tool_name in TOOL_HANDLERS and tool_args:
handler = TOOL_HANDLERS[tool_name]
# 执行工具
result = handler(**tool_args)
# 将结果格式化为给模型看的观察文本
observation = f"工具 `{tool_name}` 的执行结果:\n{json.dumps(result, ensure_ascii=False, indent=2)}"
return True, tool_name, observation
else:
return False, None, "未解析到有效的工具调用指令。"
关键注意事项与避坑指南 :
- 安全!安全!安全! :这是最重要的部分。永远不要相信模型直接提供的文件路径或命令。必须进行严格的校验,防止路径遍历(
../../../etc/passwd)或执行危险命令(rm -rf /)。上面的代码展示了简单的路径校验,生产环境需要更完善的沙箱机制。 - 错误处理 :工具执行可能失败(文件不存在、权限不足、网络超时)。必须捕获所有异常,并将清晰的错误信息返回给模型,让它能根据错误调整策略。
- 结果格式化 :工具执行结果(无论是成功的数据还是错误信息)必须以清晰、结构化的文本格式返回给模型,作为它下一轮思考的“观察”(Observation)。通常使用JSON字符串便于模型解析。
- API兼容性 :OpenAI的Chat Completion API原生支持
tools参数和tool_calls响应字段,模型会直接输出结构化的调用请求,这比从文本中解析更稳定。如果你的后端使用此类API,应优先采用原生方式。我们的文本解析方式是一种更通用、兼容不同API的方案。
4. 核心模块三:ReAct循环——构建模型的“思考-行动”链
有了System Prompt和Function Calling,我们还需要一个驱动引擎,让模型能够循环地进行“思考-行动-观察”,直到解决问题。这就是ReAct(Reasoning + Acting)范式。它不是一次性的问答,而是一个多轮交互的循环。
4.1 ReAct循环的工作流程
一个典型的ReAct循环步骤如下:
- 用户输入 :开发者提出请求,如“在项目根目录创建一个
utils.py文件,里面写一个计算斐波那契数列的函数。” - 模型思考(Reason) :模型根据System Prompt,在【思考】部分分析:“用户想创建一个Python工具文件。我需要先确认项目结构,看看是否已存在同名文件,然后生成符合规范的代码。”
- 模型行动(Act) :模型决定调用工具。在【行动】部分输出:
<action>read_file</action>参数为当前目录列表或检查文件是否存在。 注意 :首次行动可能不是直接执行最终任务,而是先探索环境。 - 系统执行与观察(Observe) :后端解析行动指令,调用
read_file(或list_dir)工具,获取结果,并将结果作为“观察”文本反馈给模型。例如:“观察:当前目录下不存在utils.py文件。” - 模型再思考与再行动 :模型接收到观察结果,继续思考:“文件不存在,可以直接创建。现在需要生成斐波那契函数的代码。” 然后行动:
<action>write_file</action>,并附上生成的代码内容。 - 循环终止与最终输出 :工具执行成功,模型判断任务已完成。它在【最终答案】部分输出总结:“已成功创建
utils.py文件,包含函数fibonacci(n)。该函数使用了迭代法,时间复杂度为O(n)。” - 循环或结束 :如果任务未完成(例如,写文件失败,或用户提出了更复杂的需求),则重复步骤2-6。
4.2 后端实现ReAct循环控制器
class ReActAgent:
def __init__(self, llm_client, system_prompt, max_turns=10):
self.llm = llm_client
self.system_prompt = system_prompt
self.max_turns = max_turns # 防止无限循环
self.conversation_history = []
def run(self, user_input):
"""执行一次完整的ReAct任务循环"""
# 初始化对话
messages = [
{"role": "system", "content": self.system_prompt},
*self.conversation_history,
{"role": "user", "content": user_input}
]
for turn in range(self.max_turns):
print(f"\n--- 第 {turn+1} 轮思考 ---")
# 1. 调用模型,获取响应
try:
full_response = ""
# 这里假设调用非流式API获取完整响应
response = self.llm.chat.completions.create(
model="gpt-4",
messages=messages,
temperature=0.1,
stream=False
)
model_message = response.choices[0].message.content
full_response = model_message
except Exception as e:
# 处理API错误,如429限速、503服务不可用等
return f"调用模型API时出错:{str(e)}"
print(f"模型原始响应:\n{full_response}")
# 2. 解析响应,判断是否包含工具调用
has_tool_call, tool_name, observation = parse_and_execute_tool_call(full_response)
if has_tool_call:
print(f"检测到工具调用: {tool_name}")
print(f"工具执行结果: {observation}")
# 3. 将工具执行结果(观察)作为新消息附加到对话历史,让模型继续
# 格式可以是: role: “user”, content: f“Observation: {observation}”
messages.append({"role": "user", "content": f"Observation: {observation}\n请基于以上观察继续你的任务。"})
# 同时,也把模型的这次响应和我们的观察记录到总历史中
self.conversation_history.append({"role": "assistant", "content": full_response})
self.conversation_history.append({"role": "user", "content": f"Observation: {observation}"})
# 继续下一轮循环
continue
else:
# 4. 没有工具调用,说明模型给出了最终答案
print(f"模型给出最终答案,循环结束。")
# 将最终响应加入历史
self.conversation_history.append({"role": "assistant", "content": full_response})
# 返回最终答案部分(可能需要从响应文本中提取)
final_answer = self._extract_final_answer(full_response)
return final_answer
# 如果达到最大轮数仍未结束
return f"任务未在{self.max_turns}轮内完成,可能陷入循环。最后响应:{full_response}"
def _extract_final_answer(self, response):
"""一个简单示例,从遵循我们格式的响应中提取【最终答案】部分"""
if "【最终答案】" in response:
parts = response.split("【最终答案】")
return parts[-1].strip()
return response
关键注意事项与调试技巧 :
- 防止无限循环 :
max_turns是必须的安全阀。模型有时会在“思考-调用-观察”中陷入死循环(例如,反复读取同一个文件却得不出结论)。需要设置上限,并在达到上限时终止,给出提示。 - 上下文管理 :每一轮的“思考-行动-观察”都会增加对话历史长度,加剧上下文窗口压力。需要实现上文提到的历史摘要或选择性遗忘策略,只保留最关键的信息。
- 观察信息的设计 :反馈给模型的“观察”信息要简洁、相关。不要一股脑把原始日志丢进去。例如,工具返回了一个大JSON,你可以提取关键字段再反馈。
- 处理模型“不听话” :即使有严格的System Prompt,模型偶尔也会不按格式输出。你的解析函数
parse_and_execute_tool_call需要有足够的鲁棒性,能处理格式上的小偏差,或者能检测到格式错误并给模型一个纠正性的提示,让它重试。
5. 系统集成与实战调试
将上述三个核心模块组装起来,就是一个最小可行(MVP)的智能体后端。接下来,你需要为这个后端提供一个API接口(例如使用FastAPI),让VSCode插件(前端)能够与之通信。
5.1 构建API服务
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import uvicorn
app = FastAPI(title="Claude Code 智能体后端")
# 全局智能体实例
agent = ReActAgent(llm_client=openai_client, system_prompt=detailed_system_prompt)
class ChatRequest(BaseModel):
message: str
session_id: str = None # 可选,用于支持多会话
class ChatResponse(BaseModel):
response: str
session_id: str
@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(request: ChatRequest):
"""
核心聊天端点。前端VSCode插件发送用户消息到这里。
"""
try:
# 这里可以根据session_id获取或创建不同的对话历史上下文
user_input = request.message
final_response = agent.run(user_input)
return ChatResponse(response=final_response, session_id=request.session_id or "default")
except Exception as e:
# 记录详细日志,但返回给前端的错误信息要友好
print(f"API处理错误: {e}")
raise HTTPException(status_code=500, detail="智能体处理请求时发生内部错误。")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
5.2 常见问题排查与优化实录
在实际搭建和运行过程中,你几乎一定会遇到下面这些问题。以下是我的排查笔记:
问题1:API Error 400 - ‘type’ must be in [“enabled”, “disabled”, “auto”]
- 现象 :调用某些特定模型(如一些国内厂商的兼容API)时,在请求参数中返回此错误。
- 根因 :你使用的API客户端库或你手动构造的请求体,可能包含了目标API不支持的参数。例如,某些参数在OpenAI API中是
function_call,而在其他平台可能是tools或functions,且枚举值不同。 - 解决 :仔细核对目标模型提供商的最新API文档。使用一个纯HTTP请求(如
curl或requests库)先测试最简单的调用,确保参数格式完全正确,再集成到你的代码中。不要盲目复制其他模型的示例代码。
问题2:API Error 400 - maximum context length is ... tokens
- 现象 :对话进行到一定轮数后,突然失败。
- 根因 :累计的对话历史(System Prompt + 所有用户/助手消息)超过了模型的最大上下文窗口。
- 解决 :
- 统计Token :在每次添加消息到历史前,使用
tiktoken(对于OpenAI模型)或模型提供商提供的tokenizer估算token数。 - 实现滑动窗口 :只保留最近N轮对话(例如最近10轮)。
- 实现历史摘要 :当历史过长时,调用模型本身对之前的对话进行总结,用一段简短的摘要替换掉大量旧消息。这是一个高级但非常有效的策略。
- 精简System Prompt :在保证效果的前提下,删除不必要的描述性语言。
- 统计Token :在每次添加消息到历史前,使用
问题3:API Error 429 - Rate limit exceeded 或 The engine is currently overloaded
- 现象 :请求被频繁拒绝。
- 根因 :请求频率或并发数超过了API的限制。
- 解决 :
- 实现退避重试 :在代码中添加指数退避重试逻辑。遇到429错误时,等待一段时间(如2秒、4秒、8秒...)再重试。
- 降低请求频率 :在客户端(你的后端)控制发送请求的节奏,特别是ReAct循环中可能连续快速调用API。
- 使用队列 :对于高并发场景,将请求放入队列,按顺序处理。
- 检查配额 :确认你的API账户是否有足够的额度或请求次数。
问题4:模型不调用工具,或总是调用错误工具
- 现象 :模型在应该调用工具时选择了直接回答,或者调用了不相关的工具。
- 根因 :
- System Prompt中对工具的描述不够清晰,或与用户问题关联性不强。
- 工具的参数
description写得太模糊。 - 模型温度(
temperature)设置过高,导致行为不稳定。
- 解决 :
- 优化Prompt :在System Prompt中更明确地指出“当你需要获取你不知道的信息或执行操作时, 必须 使用工具”。给出具体的例子。
- Few-Shot示例 :在System Prompt或初始对话中,提供一两个用户提问、模型正确调用工具并解决问题的完整示例(Few-Shot Learning),效果极佳。
- 降低温度 :对于工具调用这类需要确定性的任务,将
temperature设为0或接近0(如0.1)。 - 在用户提问中引导 :如果用户问“当前目录下有什么文件?”,你可以稍微引导:“请使用你拥有的工具来查看当前目录。”这能显著提高工具调用的触发率。
问题5:工具调用结果解析失败
- 现象 :模型输出了类似
<action>read_file</action>的文本,但你的解析函数没识别出来。 - 根因 :模型输出格式有轻微变异,比如多了空格、换行,或者用了中文括号。
- 解决 :
- 使用更健壮的解析 :不要依赖精确的字符串匹配。使用正则表达式来提取被
<action>...</action>包裹的内容,并使用json.loads()的strict=False模式或先尝试修复常见的JSON格式错误(如尾随逗号)。 - 让模型自我纠正 :如果解析失败,将错误信息(“未能识别你的行动指令”)作为观察反馈给模型,并要求它严格按照格式重试。通常模型会立刻纠正。
- 使用更健壮的解析 :不要依赖精确的字符串匹配。使用正则表达式来提取被
搭建这样一个智能体后端,就像教一个天赋异禀但初入社会的实习生:System Prompt是员工手册,Function Calling是给他授权的工具和权限,ReAct循环是你管理他“汇报-执行-再汇报”的工作流程。三者缺一不可,且都需要精心设计和反复调试。这个过程没有银弹,需要你根据实际使用的模型和具体任务,不断地调整Prompt、优化工具定义、完善循环逻辑。当你看到模型能主动读取文件、分析代码、并生成正确的修改时,那种感觉就像你亲手赋予了一段代码以“生命”,之前的所有折腾都值了。
更多推荐

所有评论(0)