news 2026/8/27 15:39:32

AI Agent工具调用安全:预执行门禁的设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI Agent工具调用安全:预执行门禁的设计与实现

如果你正在把一个 AI Agent 接进生产环境,有一件事迟早会拦在你面前:你怎么确认模型生成的工具调用不会删库、发邮件、写坏文件,或者把敏感数据暴露给不该看的人?

靠提示词约束,靠模型自觉,靠事后审计,都不够。工具调用的副作用一旦发生,补救就已经晚了。真正可靠的做法,是在执行之前就把不安全的调用拦下来。

Pyshackle 这个开源项目的定位,恰好切中这个最关键的环节。从项目命名就能看出它的立场:shackle 是镣铐,pyshackle 就是用 Python 给 AI Agent 的工具调用戴上镣铐,而且是戴在执行之前。它不是给模型讲道理,而是在工程链路上加一道无法绕过的硬门禁。

本文会围绕 Pyshackle 这类"预执行门禁"的设计思路展开,先讲清楚它解决的是什么问题,再对比它与传统安全手段的差异,最后给出一个最小可运行的 Python 实现,以及在实际 Agent 工程中的接入要点和排查思路。读完你可以自己评估:你的 Agent 项目是否也需要这样一道门。

1. Agent 工具调用:能力越强,越需要一道执行前的闸门

现在的 Agent 已经不再是"只能聊天"的对话机器人。主流框架里,Agent 可以调用搜索、读写文件、执行 Shell 命令、访问数据库、调用外部 API,甚至触发支付流程。这些能力让 Agent 从"建议者"变成了"执行者",也让安全边界从"回答问题"扩展到了"操作真实系统"。

一个典型的例子:你让 Agent 帮忙整理项目报告,完整执行链可能包含读取多个源码文件、查询 Git 历史、运行测试命令、生成新文件。如果模型的工具调用决策出现问题——比如读取了不该读的密钥文件,或者执行了rm -rf——后果不是一段错误的文字,而是真实的数据损失。

更麻烦的是,模型本身不具备可靠的安全判断力。大语言模型的输出是概率性的,它对一条指令的理解会受到上下文影响。攻击者通过提示注入,完全可能在系统提示词和用户输入之间制造一个工具调用,让 Agent 把本不该执行的操作当成任务的一部分执行。如果安全靠模型"自觉",等于把系统安全建立在最无法保证可靠的环节上。

这就是预执行门禁存在的理由:把安全判断从模型手里拿走,交给一段确定性的、可测试的、符合策略的工程代码。模型可以"决定想做什么",但系统只允许它做白名单里允许的事。决定权和执行权被分离,"幻想的操作"和"实际的操作"之间的差距,就是门禁的用武之地。

从工程角度看,Agent 越强大、调用工具越频繁、权限越高,预执行门禁就越不是可选项,而是必选项。Pyshackle 这类项目之所以有价值,不是因为它发明了多么复杂的安全算法,而是它抓住了问题链条上最核心的起点:在工具调用的副作用产生之前,完成拦截。

2. Pyshackle 是什么:把"镣铐"装在最关键的位置

Pyshackle 从项目名称上已经表达了核心立场。拆开看它的定位,有三个关键词需要注意。

"Pre-execution" 表示门禁的时机。它发生在工具调用真正执行之前,也就是 LLM 输出一段工具调用结构、但执行器还未触发副作用的时间窗口。在这个窗口内,调用可以被审查、放行、拒绝或转入人工审批。一旦错过这个窗口,任何补救都是后置的。

"Hard" 表示门禁的强度。它不是提示词层级的"建议",比如告诉模型"请不要删除文件",也不是模型在推理过程中自己对自己说"等等,这个操作有风险",而是工程代码层面的强制拦截。无论模型生成的调用看起来多么合理,只要没有通过门禁校验,执行器就不会收到执行指令。

"Gate" 则点明了这个组件的架构身份。它像一个过滤器,插在 Agent 核心循环之间。所有工具调用都必须经过这道闸门,要么通过,要么被拦下,没有第三条路。

从 Pyshackle 的定位来看,它要解决的是 Agent 工具调用中最让人不放心的一类问题:模型有足够的自由度进行规划,但实际执行的操作必须受控。一个直观类比是机场安检。模型是旅客,可以自由选择目的地、规划路线,但进入航站楼之前必须通过安检通道。安检不关心你想去哪,它只关心你有没有带不该带的东西。Pyshackle 的设计思路类似:统一检查所有工具调用,不符合策略的一律不放行,而不是在模型"想通"之后再行动。

需要说明的是,本文基于 Pyshackle 这类项目的公开定位来阐述预执行门禁的设计。如果你需要了解它最新的安装命令、API 签名或框架适配细节,建议以项目文档和代码仓库为准。本文重点解决的是"理解这个思路、并能自己动手实现同类防护"的问题。

3. 预执行门禁在 Agent 安全体系中的定位

要真正理解预执行门禁的价值,需要先看清它在 Agent 安全体系中的位置。很多人一想到 Agent 安全,就会联想到权限系统、沙箱、审计日志,这些确实都是必要组件,但它们的视角各不相同。

先说权限系统。RBAC、ABAC 解决的是"谁有权做什么"的问题:用户 A 能不能读取目录 B,服务 C 能不能访问数据库 D。权限系统通常在框架层面提供基础限制,但它的粒度往往不够细,而且 Agent 的工具调用往往横跨多个资源,权限判断难以下沉到单次调用。

再说沙箱隔离。沙箱解决的是"即使执行了,也不会影响外部系统"的问题。工具在一个受限环境里运行,比如容器、虚拟机、单独的用户空间。沙箱很有必要,但它有一个天然缺陷:它不区分"安全操作"和"危险操作",它只是让所有操作都跑在笼子里。如果 Agent 在沙箱里调用了rm -rf /tmp,沙箱会照常执行。

还有事后审计与回滚。审计记录能告诉你"发生了什么",回滚能尝试"消除已发生的副作用"。但对副作用已经传导到外部系统的操作,比如发送邮件、转账、修改线上数据,回滚几乎是无能为力的。事后发现错误,损失已经真实发生了。

预执行门禁的独特价值在于,它是从"时间"和"意图"两个维度同时介入。时间上,它位于副作用发生之前;意图上,它可以通过规则判断这个工具调用的参数是否在允许范围内。它与权限系统互补:权限系统回答"谁可以",门禁回答"这一次调用能不能执行"。它与沙箱互补:沙箱保证"执行坏不了外面",门禁保证"不该执行的压根不执行"。它与审计互补:审计记录门禁放行或拦截的结果,让每一条调用都有迹可循。

可以用表格更直观地看差异:

安全机制介入时机核心问题局限
权限系统执行前谁有权做什么粒度粗,难以覆盖单次调用
沙箱隔离执行中执行了也不影响外部不区分善恶,只隔离环境
审计与回滚执行后发生了什么,怎么补救副作用已经发生,补救有限
预执行门禁执行前这次调用能不能做需要维护准确策略,可能误拦截

从这张表可以看出一条清晰的结论:预执行门禁是整个 Agent 工具调用链路上最靠前、也最能避免损失的安全节点。它不替代其他机制,而是把安全防线往前移了一步。

4. 为什么必须是"硬"门禁

如果只做一个"执行前检查模型输出是否危险"的工具,其实有很多软性实现方案。比如在提示词里加一句"不要执行危险操作",或者在模型输出后让模型自己再反思一次。这些做法看起来成本低、接入快,但可靠性很成问题。

硬门禁和软提醒之间最本质的区别在于:关卡是否可以被绕过,决策是否由确定性代码做出。

软提醒的问题首先是可绕过性。提示词注入可以覆盖系统指令,恶意输入可能让模型忽略安全约束;一个精心构造的用户消息可能让模型认为"删除文件正是当前任务需要的操作"。即便模型在大多数时候是可靠的,只要存在哪怕千分之一的概率产生危险工具调用,在生产系统中这个概率就会变成不得不面对的现实风险。

软提醒的第二个问题,是责任归属模糊。当一次危险调用是由模型"自主决策"产生的,你很难在工程上界定这是 bug、是用户攻击、还是产品缺陷。而硬门禁把安全决策明确归属到可测试的策略代码上:策略说不行,就是不行,这与模型的随机性完全无关。

硬门禁还有一个容易被忽视的好处:它把 Agent 的能力边界变得可配置、可观测、可审计。团队不需要依赖"模型应该不会做危险的事"这种假设,而是可以在配置文件中明确声明:哪些工具能调用,哪些参数范围合法,哪些操作需要人工审批。安全策略变成了代码,可以进入版本管理、代码评审和持续测试流程。

当然,硬门禁不是万能的。它需要认真设计策略,否则容易误拦截,让 Agent 频繁碰壁,效率降低。它也不能解决所有安全问题,比如恶意模型本身可能构造伪装调用。但在"模型会犯错、输入不可信"的前提下,硬门禁是防范工具调用风险最可靠的第一道闸门。

Pyshackle 定位为硬门禁,正是因为它的设计目标不是降低风险,而是让不安全调用根本无法执行。软提醒是对概率下注,硬门禁是对确定性负责。

5. 最小实现:给工具调用加一个可运行的预执行门禁

理解了预执行门禁的原理之后,最好的学习方式是自己动手实现一个最小版本。下面的示例代码展示一个完整的拦截流程,包含 GateKeeper 决策、策略规则、审计日志和执行拦截。它不是 Pyshackle 的源码,但体现了同类设计的核心逻辑。

文件结构如下:

gate_demo/ ├── policies.json ├── policy.py └── main.py

先看策略模块policy.py

# gate_demo/policy.py from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional import re class Decision(str, Enum): ALLOW = "allow" DENY = "deny" APPROVAL_REQUIRED = "approval_required" @dataclass class ToolCall: name: str arguments: Dict[str, Any] call_id: str @dataclass class PolicyRule: tool_name: str allow: bool = True requires_approval: bool = False arg_constraints: Dict[str, Dict[str, Any]] = field(default_factory=dict) def evaluate(self, call: ToolCall) -> Optional[str]: """返回 None 表示通过;返回字符串表示拒绝原因。""" if not self.allow: return f"{call.name} is explicitly disabled" if self.requires_approval: return "approval required" for arg_name, constraints in self.arg_constraints.items(): if arg_name not in call.arguments: return f"missing required argument: {arg_name}" value = call.arguments[arg_name] prefix = constraints.get("prefix") if prefix and not str(value).startswith(prefix): return f"argument {arg_name} does not start with {prefix}" pattern = constraints.get("regex") if pattern and not re.match(pattern, str(value)): return f"argument {arg_name} does not match regex {pattern}" return None class GateKeeper: """门禁:持有全部策略规则,对一次 ToolCall 做出决策。""" def __init__(self, rules: List[PolicyRule]): self._rules = {rule.tool_name: rule for rule in rules} def check(self, call: ToolCall) -> Decision: rule = self._rules.get(call.name) if rule is None: # 默认拒绝:没有显式策略的工具调用一律拦截 return Decision.DENY reason = rule.evaluate(call) if reason is None: return Decision.ALLOW if rule.requires_approval: return Decision.APPROVAL_REQUIRED return Decision.DENY

这里是核心决策逻辑。GateKeeper.check会根据工具名找到对应规则,然后执行三段式决策:规则不存在则拒绝;规则通过则放行;规则要求审批则进入审批流程。注意allow: false的规则优先级最高,即使同时设置了requires_approval,也会直接拒绝,不会把危险操作推向人工审批。

配置文件policies.json

{ "policies": [ { "tool_name": "read_file", "allow": true, "arg_constraints": { "path": { "prefix": "/workspace/project_a" } } }, { "tool_name": "execute_shell", "allow": true, "requires_approval": true }, { "tool_name": "delete_file", "allow": false, "requires_approval": true } ] }

这份策略表达了三种典型语义:read_file只允许读取指定项目目录下的文件;execute_shell允许调用但每次都需要人工审批;delete_file直接禁止。未出现在配置文件中的工具,比如write_file,会被默认拒绝。这种"默认拒绝"的策略是预执行门禁的基本要求,只有出现在白名单里的调用才有资格被讨论。

最后是主程序main.py

# gate_demo/main.py import json from gate_demo.policy import GateKeeper, PolicyRule, ToolCall, Decision def load_policies(path: str) -> list[PolicyRule]: with open(path, "r", encoding="utf-8") as f: data = json.load(f) return [PolicyRule(**item) for item in data["policies"]] def fake_read_file(path: str) -> str: return f"[content of {path}]" def log_audit(call: ToolCall, decision: Decision, detail: str = "") -> None: print(f"[audit] call_id={call.call_id} tool={call.name} decision={decision.value} {detail}") def run_with_gate(call: ToolCall, gate: GateKeeper, executor) -> Any: decision = gate.check(call) if decision == Decision.DENY: log_audit(call, decision, "denied by policy") raise PermissionError(f"{call.name} blocked by gate") if decision == Decision.APPROVAL_REQUIRED: log_audit(call, decision, "waiting for human approval") raise PermissionError(f"{call.name} requires human approval") log_audit(call, decision, "allowed") return executor(**call.arguments) EXECUTORS = { "read_file": fake_read_file, } def main(): gate = GateKeeper(load_policies("policies.json")) calls = [ ToolCall("read_file", {"path": "/workspace/project_a/README.md"}, "call-001"), ToolCall("read_file", {"path": "/etc/passwd"}, "call-002"), ToolCall("execute_shell", {"command": "ls /tmp"}, "call-003"), ] for call in calls: executor = EXECUTORS.get(call.name) if executor is None: print(f"[warn] no executor for tool: {call.name}") continue try: result = run_with_gate(call, gate, executor) print("result:", result) except PermissionError as e: print("blocked:", e) if __name__ == "__main__": main()

运行方式:

cd gate_demo python main.py

预期输出:

[audit] call_id=call-001 tool=read_file decision=allow allowed result: [content of /workspace/project_a/README.md] [audit] call_id=call-002 tool=read_file decision=deny denied by policy blocked: read_file blocked by gate [audit] call_id=call-003 tool=execute_shell decision=approval_required waiting for human approval blocked: execute_shell requires human approval

三条调用展示了三种决策路径:合法路径放行、越权路径拒绝、高危路径转人工审批。在这个最小实现中,门禁的逻辑和执行器是完全解耦的,你可以把EXECUTORS替换成真实工具,把run_with_gate嵌入到你正在使用的 Agent 框架调用链中。

这里有一个关键设计值得留意:门禁与执行器之间没有绕过路径。run_with_gate是工具执行的唯一入口,外部代码不能绕过gate.check直接调用 executor。在实际项目中,这个约束同样重要。如果 Agent 框架存在多个执行器入口,门禁就必须做成统一网关,否则就会出现"门禁亮着绿灯,执行器走别的门出发"的尴尬局面。

6. 在实际 Agent 工程中如何接入

上面这份最小实现展示了门禁的内部逻辑,但在真实项目中,接入方式取决于你使用哪个 Agent 框架、工具注册表是怎么设计的、以及 Agent 循环在哪个位置产出工具调用。

主流 Agent 框架通常会把工具调用链路分为四步:模型输出结构化工具调用、框架解析调用、执行器执行、结果返回模型。预执行门禁应该插入在"框架解析调用"和"执行器执行"之间。这一位置可以保证每次调用都被检查,同时不会干扰模型本身的推理过程。

在基于 LangChain 架构的项目里,常见做法是通过自定义 Tool 或 middleware 来统一处理。如果工具的调用入口被抽象成了统一的Tool.run()方法,那么门禁的逻辑就适合封装在一个装饰器或超类方法中。例如把run_with_gate包装成装饰器,让每个工具的run方法在真正执行前先经过 GateKeeper:

def gate_tool(gate: GateKeeper, executor): def wrapper(call: ToolCall): decision = gate.check(call) if decision == Decision.DENY: raise PermissionError(f"{call.name} blocked by gate") if decision == Decision.APPROVAL_REQUIRED: raise PermissionError(f"{call.name} requires human approval") return executor(**call.arguments) return wrapper

采用这种包装方式,原有工具的注册逻辑不需要大改,只需把executor替换为被 wrapper 包裹的版本。如果项目使用的是 LangGraph 这类状态化框架,还可以把门禁实现为节点之间的条件边:模型节点之后增加一个门禁节点,根据gate.check的结果决定下一步走向执行节点还是终止节点。

另一个常见的接入位置是事件回调。很多 Agent 框架在工具调用之前会触发事件钩子,比如on_tool_start。如果你不想改动工具注册逻辑,可以在回调里完成门禁检查,发现不合法调用时抛异常或终止链路。这种方式侵入性最小,但要确认框架在回调抛出异常时确实不会继续执行工具,否则门禁形同虚设。

无论采用哪种接入方式,有几个通用原则都需要遵守。

一是保证门禁在唯一路径上。如果 Agent 有多个工具调用入口,比如主 Agent 和子 Agent 各有一套执行器,所有入口都必须经过同一个 GateKeeper 实例和同一套策略配置,不能存在未经过门禁的旁路。

二是策略配置要能热更新。生产环境中策略会频繁调整,比如临时禁止某个工具、收紧某个参数前缀、增加一个审批规则。如果每次改策略都要重新发布服务,团队会被拖垮。比较稳妥的做法是策略文件放到配置中心或持久化存储中,GateKeeper 定期刷新,并对比版本号判断配置是否已更新。

三是审批要设置超时。APPROVAL_REQUIRED状态如果一直卡在人工审批队列里,Agent 任务会长时间挂起。建议为审批设置超时时间,超时后默认拒绝,并让 Agent 拿到一个明确的"操作被拒绝"的结果,从而调整计划。

四是对门禁结果做结构化日志。日志不仅仅为了定位问题,它还是策略调优的基础数据。建议记录工具名、参数摘要、决策结果、触发规则、审批人、耗时等字段。长期积累这些日志后,你会发现哪些策略经常误拦截、哪些工具的高危调用频繁被拦下,这些数据远比拍脑袋调策略更有说服力。

7. 常见问题与排查思路

预执行门禁接入后,最常遇到的问题并不是"门禁失效",而是"门禁误伤"。下面这份排查表总结了几个典型场景,供你在实际项目中对照参考。

问题现象可能原因排查方式解决方案
合法工具调用被拒绝策略规则过严,或工具名未注册查看门禁日志中决策详情,确认命中的规则在策略白名单中补充工具,或放宽参数约束
Agent 绕过了门禁存在多个工具执行入口,有的入口未接入 GateKeeper检查框架中所有工具调用路径,确认门禁是否唯一统一执行入口,把门禁封装为唯一工具网关
危险工具调用未被拦截规则配置错误,allow 误设为 true检查策略文件中该工具规则及参数约束修正配置,必要时引入"默认拒绝"基线
人工审批队列堆积审批超时设置过长,或审批任务过多查看审批队列长度和平均处理时长收紧审批范围,给 Agent 配置降级策略
策略更新后不生效GateKeeper 缓存了旧配置检查配置刷新机制及版本号增加版本比对,或定期强制刷新策略
门禁日志不完整仅在有异常时打日志,放行调用未记录检查审计日志模块的覆盖范围对所有决策结果统一输出结构化日志
误拦截率过高,Agent 频繁失败参数约束过死,与真实业务路径不符分析被拦截的参数分布,整理合法路径结合业务路径放宽约束,或改为审批模式

排查门禁问题的第一原则永远是先看日志。门禁是确定性的工程组件,一次调用被拦截一定有一个明确的原因。不要凭直觉猜,从[audit]日志中定位是哪个规则、哪个参数触发了拦截,这通常能直接定位到问题。

另外一个容易被忽略的点是工具的命名规范。Agent 框架里工具名往往就是代码中的函数名,如果工具名不稳定、重构后改名,策略文件里旧名称就会失配,导致原本允许的工具突然被默认拒绝。建议在工具注册表中维护一份名称映射,并让策略的校验基于工具 ID 而不是展示名称。

8. 最佳实践:从单点门禁走向安全体系

预执行门禁不是银弹。要真正把 Agent 工具调用风险控制住,需要把门禁放入一个完整的安全体系。这部分说几个在项目中验证过比较有效的实践方向。

默认拒绝应该是门禁策略的基线。默认允许配合黑名单是常见的反面教材:名单永远不完整,新增工具忘记加入黑名单,风险就悄然出现。默认拒绝则把风险反转过来,新增工具没有被显式放行,就自动不可用,开发人员必须主动申请策略配置才会被放行。这正是最小权限原则在 Agent 工具调用上的体现。

参数约束是比工具名更重要的防线。很多团队只做工具级白名单,比如允许read_file,但忽略参数范围,结果模型依然可以读取任意路径。在门禁策略中,参数级约束往往比工具级控制更有价值。路径前缀、命令模板、域名白名单、金额上限,这些约束能把"调用这个工具"压缩成"在允许的业务范围内调用这个工具"。

分级审批应对的是高风险操作的效率问题。所有高危调用都走人工审批的必要性要结合业务评估。一刀切全部要求审批,Agent 的自动化价值就浪费了。更合理的模式是三级决策:低风险调用直接放行,中风险调用根据参数自动判定,高风险调用强制人工审批。自动化门禁解决量,人工审批解决那些自动判断无法覆盖的关键决策。

门禁和沙箱应该组合使用,而不是二选一。门禁在前,挡住绝大多数非预期调用;沙箱在后,兜住少数通过门禁但在运行中产生意外行为的调用。尤其在高危场景,比如执行外部代码、批量删除操作,建议门禁审批通过后,实际执行仍然在容器或隔离环境中进行。

审计日志是安全体系的事实基础。门禁每次决策都需要记录完整信息,包括原始调用参数、策略版本、决策结果、审批信息、执行结果。日志至少保留一段时间,支持按工具名、操作人、权限范围检索。在事故复盘时,门禁日志能告诉你哪些调用被拦住了、哪些被放行了、放行的时候策略长什么样,这比"看起来没问题"可靠得多。

策略版本化和灰度发布也是必要的工程手段。一次策略改动可能影响所有 Agent 任务,建议像发布代码一样发布策略:先在一小部分流量上验证,观察误拦截率和审批队列长度,再逐步全量生效。如果策略可以回滚到上一个版本,对生产环境的风险会显著降低。

9. 写在最后:开源安全工具的价值和下一步

Pyshackle 这类开源项目的价值,不只是它那份可以直接使用的代码,更在于它指出了一个正确的问题方向:Agent 的工具调用要真正走向生产,安全问题不能依赖模型进化,必须在工程链路上主动设防。

如果你正在把 Agent 接入真实业务,建议从梳理工具调用路径开始,画清楚模型输出之后、执行器执行之前的所有环节,然后在这个路径上增加一道门禁。Pyshackle 是一个可参考的开源实现,而你完全可以在理解它的设计思路之后,用本文的最小示例作为起点,实现一套符合自己项目需求的版本。

值得继续深入的方向包括:如何与主流 Agent 框架深度集成、如何设计更适合业务语义的策略语言、如何对门禁误拦截率做自动化分析,以及如何把审批流与现有工单系统打通。安全是一个演进过程,预执行门禁只是一个好的开始,但它确实能让你在 Agent 走出实验室、进入生产环境的那一天,多一份底气和把握。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/27 15:39:20

4399手游模拟器推荐 玩4399手游用什么模拟器好

不少情怀玩家偏爱游玩4399系列手游,手机游玩却常受发热、运行卡顿等问题困扰,选对适配的4399手游模拟器,就能彻底改善这类游玩体验。市面上多数模拟器对4399手游适配优化不足,容易出现兼容出错、画面模糊等问题,哪款43…

作者头像 李华
网站建设 2026/8/27 15:39:06

基于ONNXRuntime的YOLOv8 C++部署:检测、分割、旋转框全攻略

简介:深度学习模型的工程落地往往面临跨语言、跨平台的挑战,而推理引擎的选择直接决定部署效率与兼容性。ONNXRuntime作为跨平台推理框架,能够统一加载目标检测、实例分割等任务模型,配合OpenCV完成图像预处理与结果可视化&#x…

作者头像 李华
网站建设 2026/8/27 15:37:31

用 JPEXS Free Flash Decompiler 做 SWF 反编译与资源提取

用 JPEXS Free Flash Decompiler 做 SWF 反编译与资源提取 【免费下载链接】jpexs-decompiler JPEXS Free Flash Decompiler 项目地址: https://gitcode.com/gh_mirrors/jp/jpexs-decompiler JPEXS Free Flash Decompiler(社区常简称为 FFDec)是一…

作者头像 李华