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,应该包含以下几个层次:

  1. 角色与目标定义 :明确告诉模型“你是谁”和“你要干什么”。这比“有帮助的助手”具体得多。

    你是一个专业的软件开发助手,集成在IDE中。你的主要目标是理解用户提出的编程问题、代码需求或调试请求,并生成准确、高效、可运行的代码解决方案。你应当优先考虑代码的正确性、可读性和最佳实践。
    
  2. 上下文与约束声明 :这是避免 400 Bad Request (特别是 context length 超限)和模型“胡言乱语”的关键。你需要明确模型的“工作环境”和“能力边界”。

    当前对话发生在集成开发环境(IDE)中。你无法直接访问互联网、执行命令行或读写用户本地文件(除非通过我提供的特定工具)。你生成的所有代码都应当是基于当前提供的文件上下文和问题描述。
    重要约束:你**必须**严格遵守以下输出格式规范。任何偏离格式的回应都将导致系统错误。
    
  3. 思考过程与输出格式规范 :这是引导模型进行结构化思考(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 等
]

定义工具的黄金法则

  1. 描述清晰准确 description 字段要明确工具的目的和使用场景,这直接决定模型是否会正确调用它。
  2. 参数定义严谨 parameters 的Schema要完整定义每个字段的类型、描述、是否必需。设置 additionalProperties: False 可以防止模型传入未定义的参数。
  3. 工具粒度适中 :工具既不能太粗(如 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循环步骤如下:

  1. 用户输入 :开发者提出请求,如“在项目根目录创建一个 utils.py 文件,里面写一个计算斐波那契数列的函数。”
  2. 模型思考(Reason) :模型根据System Prompt,在【思考】部分分析:“用户想创建一个Python工具文件。我需要先确认项目结构,看看是否已存在同名文件,然后生成符合规范的代码。”
  3. 模型行动(Act) :模型决定调用工具。在【行动】部分输出: <action>read_file</action> 参数为当前目录列表或检查文件是否存在。 注意 :首次行动可能不是直接执行最终任务,而是先探索环境。
  4. 系统执行与观察(Observe) :后端解析行动指令,调用 read_file (或 list_dir )工具,获取结果,并将结果作为“观察”文本反馈给模型。例如:“观察:当前目录下不存在 utils.py 文件。”
  5. 模型再思考与再行动 :模型接收到观察结果,继续思考:“文件不存在,可以直接创建。现在需要生成斐波那契函数的代码。” 然后行动: <action>write_file</action> ,并附上生成的代码内容。
  6. 循环终止与最终输出 :工具执行成功,模型判断任务已完成。它在【最终答案】部分输出总结:“已成功创建 utils.py 文件,包含函数 fibonacci(n) 。该函数使用了迭代法,时间复杂度为O(n)。”
  7. 循环或结束 :如果任务未完成(例如,写文件失败,或用户提出了更复杂的需求),则重复步骤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 + 所有用户/助手消息)超过了模型的最大上下文窗口。
  • 解决
    1. 统计Token :在每次添加消息到历史前,使用 tiktoken (对于OpenAI模型)或模型提供商提供的tokenizer估算token数。
    2. 实现滑动窗口 :只保留最近N轮对话(例如最近10轮)。
    3. 实现历史摘要 :当历史过长时,调用模型本身对之前的对话进行总结,用一段简短的摘要替换掉大量旧消息。这是一个高级但非常有效的策略。
    4. 精简System Prompt :在保证效果的前提下,删除不必要的描述性语言。

问题3:API Error 429 - Rate limit exceeded The engine is currently overloaded

  • 现象 :请求被频繁拒绝。
  • 根因 :请求频率或并发数超过了API的限制。
  • 解决
    1. 实现退避重试 :在代码中添加指数退避重试逻辑。遇到429错误时,等待一段时间(如2秒、4秒、8秒...)再重试。
    2. 降低请求频率 :在客户端(你的后端)控制发送请求的节奏,特别是ReAct循环中可能连续快速调用API。
    3. 使用队列 :对于高并发场景,将请求放入队列,按顺序处理。
    4. 检查配额 :确认你的API账户是否有足够的额度或请求次数。

问题4:模型不调用工具,或总是调用错误工具

  • 现象 :模型在应该调用工具时选择了直接回答,或者调用了不相关的工具。
  • 根因
    • System Prompt中对工具的描述不够清晰,或与用户问题关联性不强。
    • 工具的参数 description 写得太模糊。
    • 模型温度( temperature )设置过高,导致行为不稳定。
  • 解决
    1. 优化Prompt :在System Prompt中更明确地指出“当你需要获取你不知道的信息或执行操作时, 必须 使用工具”。给出具体的例子。
    2. Few-Shot示例 :在System Prompt或初始对话中,提供一两个用户提问、模型正确调用工具并解决问题的完整示例(Few-Shot Learning),效果极佳。
    3. 降低温度 :对于工具调用这类需要确定性的任务,将 temperature 设为0或接近0(如0.1)。
    4. 在用户提问中引导 :如果用户问“当前目录下有什么文件?”,你可以稍微引导:“请使用你拥有的工具来查看当前目录。”这能显著提高工具调用的触发率。

问题5:工具调用结果解析失败

  • 现象 :模型输出了类似 <action>read_file</action> 的文本,但你的解析函数没识别出来。
  • 根因 :模型输出格式有轻微变异,比如多了空格、换行,或者用了中文括号。
  • 解决
    1. 使用更健壮的解析 :不要依赖精确的字符串匹配。使用正则表达式来提取被 <action>...</action> 包裹的内容,并使用 json.loads() strict=False 模式或先尝试修复常见的JSON格式错误(如尾随逗号)。
    2. 让模型自我纠正 :如果解析失败,将错误信息(“未能识别你的行动指令”)作为观察反馈给模型,并要求它严格按照格式重试。通常模型会立刻纠正。

搭建这样一个智能体后端,就像教一个天赋异禀但初入社会的实习生:System Prompt是员工手册,Function Calling是给他授权的工具和权限,ReAct循环是你管理他“汇报-执行-再汇报”的工作流程。三者缺一不可,且都需要精心设计和反复调试。这个过程没有银弹,需要你根据实际使用的模型和具体任务,不断地调整Prompt、优化工具定义、完善循环逻辑。当你看到模型能主动读取文件、分析代码、并生成正确的修改时,那种感觉就像你亲手赋予了一段代码以“生命”,之前的所有折腾都值了。

更多推荐