test_node节点

🧠 核心思考:如何安全且有效地执行"陌生代码"?

当大模型给你返回了一段代码,直接在当前 Python 进程中运行它是极其危险且不可控的。我们需要思考一下后端的架构逻辑:

  • 进程隔离的重要性: 如果使用 Python 内置的 exec() 函数,这段生成的代码会和我们的 LangGraph 主程序共享同一个内存空间。如果 Claude 写了一个死循环,或者引发了严重的内存泄漏,我们的整个智能体主进程就会直接崩溃。
  • 工业级做法(沙盒化): 在真正的工业级后端架构中,我们通常会利用 Linux 的 namespaces 和 cgroups 机制,将这段未知代码扔进一个隔离的 Docker 容器中运行,通过 gRPC 或标准输入输出(stdio)来获取结果。
  • 本阶段的折中方案(子进程): 为了在本地方便学习和调试,我们不搞那么重的容器化,但依然要坚持进程隔离 的底线。我们将使用 Python 的 subprocess 模块。我们会把 Claude 生成的代码写进一个临时的 .py 文件,然后启动一个全新的 Python 子进程去执行它,并捕获它的 stdout(标准输出)和 stderr(标准错误)。

🐍 代码实现:完善 State 与编写 Tester 节点

为了让后续的图能够根据"是否报错"进行路由跳转,我们需要在全局状态 State 中新增一个字段来记录错误信息。

Step 1: 更新我们的结构体和输出格式 (State)
复制代码
# ==========================================
# 1. 定义 Coder LLM 的输出结构 (Schema)
# 思考:这是用来约束 Claude 的,不是 Graph 的全局 State。
# 我们需要它强制输出纯代码,避免废话。
# ==========================================
class CoderOutputSchema(BaseModel):
    """大模型生成代码的结构"""

    # 强制 LLM 输出一个名为 'code' 的字段,类型为字符串
    code: str = Field(description="生成的 Python 技能代码。必须是纯代码,不能包含 markdown 标记。")

    # 字段 2:【新增】专门的测试用例代码
    test_code: str = Field(description="""针对上述代码的测试用例。必须使用 assert 语句进行断言测试。必须直接调用上述代码中的函数。例如: assert get_weather('北京') == 'Sunny'""")

    # 我们也可以让 LLM 提供一些元数据,比如函数描述
    description: str = Field(description="对这段代码功能的简要描述。")


class SkillsCreatorState(TypedDict):
    user_requirement: str
    current_code: str
    test_code: str
    # 我们可以初始化 State 时给个空列表,这里保持追加逻辑
    execution_logs: Annotated[List[str], operator.add]
    iteration_count: int
    # 【新增字段】:用于专门存储测试时的报错信息。
    # 如果代码运行成功,它就是 None;如果失败,它就存具体的报错字符串。
    # 这将是我们下一阶段进行"条件路由(条件边)"的关键判断依据。
    error_message: Optional[str]
Step 2: 编写高鲁棒性的 Tester 节点

请仔细看这段代码中的注释,我们是如何将字符串变成子进程,又是如何捕获它的"临终遗言"(报错信息)的。

复制代码
# --- 复用阶段一的 Tester 占位节点,稍作修改以适配日志 ---
def test_node(state: SkillsCreatorState) -> dict:
    """负责在一个独立的子进程中测试执行生成的代码。"""
    print(">>> [节点执行]: 进入 Tester 节点,准备执行代码...")

    func_code=state.get("current_code","")
    test_code=state.get("test_node","")

    # 边缘情况处理:如果没有代码,直接返回错误
    if not func_code:
        return {
            "error_message": "没有找到可执行的代码。",
            "execution_logs": ["[Error] 测试失败:代码为空。"]
        }


    # 进行相应的测试
    # 【核心逻辑】:在沙盒中,我们将业务代码和测试代码拼在一起运行
    # 这样测试代码就可以直接调用上面的函数了
    full_executable_code = f"{func_code}\n\n# --- 下面是自动生成的测试用例 ---\n{test_code}"

    # 1. 创建一个安全的临时文件来存放代码
    # tempfile.NamedTemporaryFile 会在操作系统临时目录建一个文件,用完可删
    with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as temp_file:
        temp_file.write(full_executable_code)
        temp_file_path = temp_file.name
    print(f"    [Tester] 已将代码写入临时文件: {temp_file_path}")

    try:
        # 2. 启动子进程执行这个临时脚本
        # capture_output=True: 截获终端里的全部输出
        # text=True: 将输出以字符串形式返回,而不是 bytes
        # timeout=5: 极其重要!防止 LLM 写了死循环(如 while True)卡死整个系统
        result = subprocess.run(
            ["python", temp_file_path],
            capture_output=True,
            text=True,
            encoding='utf-8',
            timeout=5
        )

        # 3. 分析执行结果
        if result.returncode == 0:
            # returncode 为 0 代表进程正常退出,没有未捕获的异常
            print("    [Tester] 测试通过!")
            return {
                "error_message": None,  # 清空错误信息
                "execution_logs": [f"[Success] 测试通过。输出:\n{result.stdout}"]
            }
        else:
            # returncode 非 0,说明代码抛出了异常(如语法错误、缩进错误等)
            print("    [Tester] 测试失败,捕获到异常!")
            # result.stderr 包含了完整的 traceback 报错栈
            return {
                "error_message": result.stderr,
                "execution_logs": [f"[Failed] 测试报错:\n{result.stderr}"]
            }
    except subprocess.TimeoutExpired:
        # 捕获我们设置的 5 秒超时异常
        print("    [Tester] 测试超时!代码中可能存在死循环。")
        return {
            "error_message": "代码执行超时 (超过 5 秒),请检查是否有死循环。",
            "execution_logs": ["[Failed] 运行超时。"]
        }
    except Exception as e:
        # 捕获其他不可预知的系统错误
        return {
            "error_message": f"系统执行错误: {str(e)}",
            "execution_logs": [f"[Failed] 系统错误: {str(e)}"]
        }
    finally:
        # 4. 清理现场,无论成功失败都删除临时文件
        if os.path.exists(temp_file_path):
            os.remove(temp_file_path)
            print("    [Tester] 临时文件已清理。")

with 语句与 tempfile(优雅的资源管理)

先来看这段代码:

复制代码
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as temp_file:
    temp_file.write(test_runner_code)
    temp_file_path = temp_file.name
1. 什么是 with?(Python 的 RAII)

在传统的编程中,打开一个文件后,必须在操作完成后调用 .close() 来释放文件描述符(File Descriptor)。如果中间发生了异常(比如写文件时磁盘满了抛出 Error),程序崩溃跳出了当前作用域,.close() 就永远不会被执行,从而导致资源泄漏

with 语句是 Python 中的上下文管理器(Context Manager)

  • 它的底层逻辑非常像 C++ 中的 RAII(将资源的生命周期绑定到对象的生命周期)。
  • 当程序进入 with 代码块时,它会自动帮你"获取资源"(打开文件)。
  • 当程序离开 with 代码块时(无论是正常执行完,还是中间抛出了异常),它都会强制、自动地 帮你调用底层的 .close() 方法,安全地释放文件句柄。
2. tempfile.NamedTemporaryFile 的精妙参数

这是一个专门在操作系统的临时目录(如 Linux 的 /tmp**)创建文件的安全函数**。我们看看里面参数的设计逻辑:

  • **mode='w'** encoding='utf-8':我们要写入的是大模型生成的纯文本代码,而不是二进制流,指定 UTF-8 可以防止中文字符(比如注释里的中文)乱码。
  • suffix='.py':非常关键。我们要欺骗后续的 Python 解释器,让它认为这是一个正经的 Python 脚本,所以必须加上 .py 后缀。
  • delete=False**(核心难点)**:
    • 默认情况下,临时文件一旦被关闭(即离开 with 块),操作系统就会立刻把它从硬盘上删掉。
    • 但我们不能让它删!因为我们接下来要启动一个全新的子进程 去读取这个文件。如果刚写完就删了,子进程就会报 FileNotFoundError
    • 所以我们设置 delete=False,把它强行留在硬盘上。等子进程执行完毕后,我们在后面的 finally****块里用 **os.remove()**手动清理现场
  • temp_file.name:拿到这个临时文件在操作系统上的绝对路径 (比如 /tmp/tmpxyz123.py),这是后续传递给子进程的"坐标"。

subprocess 进行子进程隔离执行

为什么我们不直接用 Python 自带的 exec(test_runner_code) 在当前程序里运行这段代码?

思考:进程空间的隔离

  • 我们的 LangGraph 主程序运行在一个主进程中,占用了特定的内存空间。
  • 如果 LLM 生成的代码里有 sys.exit(),或者写了一个死循环 while True: pass,甚至写了恶意破坏内存的代码。如果我们直接用 exec()主进程就会跟着一起崩溃或卡死。你的智能体服务就宕机了。
  • 所以,我们必须像操作 Linux 的 forkexecve 一样,派生出一个完全独立的子进程去执行这颗"定时炸弹"。
subprocess.run 的参数解析:
复制代码
result = subprocess.run(
    ["python", temp_file_path],
    capture_output=True,
    text=True,
    timeout=5 
)
  • ["python", temp_file_path]:这相当于你在 Linux 终端里手动敲下 python /tmp/tmpxyz123.py。主进程通过操作系统内核,拉起了一个新的 Python 虚拟机。
  • capture_output=True
    • 默认情况下,子进程的 print 打印(stdout)和报错信息(stderr)会直接输出到你当前的屏幕终端上,你的主程序是拿不到这些字符串的。
    • 开启这个选项后,主程序会像建立了两根管道(Pipe)一样,把子进程的所有输出偷偷"拦截"并保存下来,存放在 result.stdoutresult.stderr 中。
  • text=True:告诉系统把拦截到的二进制字节流(Bytes)直接解码成易于阅读的字符串(String)。
  • timeout=5**(生命线)** :主程序启动子进程后会进入阻塞等待。加上这个参数,主程序最多只等 5 秒。如果子进程 5 秒还没运行完(大概率是 LLM 写了死循环),主程序就会请求系统内核发送 SIGKILL 信号,强制超度这个子进程,并抛出 TimeoutExpired 异常。这就保证了我们的后端服务绝对不会被卡死。

相关推荐
元Y亨H34 分钟前
LangChain API 调用语言模型
langchain·llm
元Y亨H35 分钟前
大模型技术 LangChain 概述
langchain·llm
元Y亨H37 分钟前
大模型技术 提示词模板 概述
langchain·llm
元Y亨H6 小时前
大模型技术 模型调用结果解析 概述
google·langchain·llm
mingo_敏10 小时前
DeepAgents : 权限(Permissions)
人工智能·深度学习·langchain
眼泪划过的星空16 小时前
基于 LangChain 框架构建本地化 RAG 系统:无需调用云端 API 的完整实践
人工智能·langchain
YUS云生18 小时前
大模型学习·第41天:LangChain进阶——提示词模板与Chain链式调用
学习·langchain·c#
颜酱2 天前
13 | 使用 LangChain 生成 SQL
人工智能·python·langchain
小猪咪piggy2 天前
【AI 赋能测试】Langchain 框架的使用
人工智能·chrome·langchain
艾斯特_2 天前
langGraph
人工智能·python·langchain