
🧠 核心思考:如何安全且有效地执行"陌生代码"?
当大模型给你返回了一段代码,直接在当前 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 的
fork和execve一样,派生出一个完全独立的子进程去执行这颗"定时炸弹"。
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.stdout和result.stderr中。
- 默认情况下,子进程的
- text=True:告诉系统把拦截到的二进制字节流(Bytes)直接解码成易于阅读的字符串(String)。
- timeout=5**(生命线)** :主程序启动子进程后会进入阻塞等待。加上这个参数,主程序最多只等 5 秒。如果子进程 5 秒还没运行完(大概率是 LLM 写了死循环),主程序就会请求系统内核发送
SIGKILL信号,强制超度这个子进程,并抛出TimeoutExpired异常。这就保证了我们的后端服务绝对不会被卡死。