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 异常。这就保证了我们的后端服务绝对不会被卡死。

相关推荐
姚青&2 小时前
通过 Langchain 框架实现 ChatGPT 的使用
chatgpt·langchain
InKomorebi2 小时前
《LangChain 智能体从浅入门到深入门:模型配置、中间件体系、装饰器钩子与 invoke 调用模式全解析部分内容指南分享》(如有错误欢迎指正!)
langchain
老王熬夜敲代码2 小时前
LLM结构化的概念讲解
langchain
老王熬夜敲代码2 小时前
AI小demo
langchain
咚咚2342 小时前
Langchain 调用 Agent Skills
langchain
java资料站3 小时前
第01章:LangChain使用概述
langchain
老王熬夜敲代码4 小时前
test_node流程详解
langchain
qq_54702617912 小时前
LangChain 中间件(Middleware)
中间件·langchain
Cha0DD12 小时前
【由浅入深探究langchain】第二十集-SQL Agent+Human-in-the-loop
人工智能·python·ai·langchain