背景:Apple 供应链 AME,负责 AOI 设备导入和微米级视觉检测。写代码是自学的,主业是让产线上的相机拍到 5μm 的缺陷并且不误报。
做这个项目的起因很简单:我们的 AOI 设备每天产生上万张检测图像,大部分是 OK 的,小部分需要人工复判。我一开始只是想写个脚本,让 LLM 帮我过滤掉明显的伪缺陷------省点眼睛。
但做到后面发现,LLM 不只是能看图。它还能读 XML 配置文件里的检测参数(canny 阈值、CLAHE 对比度、NMS 的 IoU 门限),判断当前参数是不是导致误报/漏报的原因,然后自己改写配置,让上位机重新加载。
这个闭环一旦转起来------检测 → 分析 → 调参 → 复检------就不再是"AI 辅助检测",而是一个能自主优化产线设备的 Agent。
但要做到这一步,首先得解决一个问题:你的 Agent 怎么安全地、可靠地调用 20+ 个不同类型的工具?
一、工具不只是"函数调用"
刚开始我的工具就是一个字典:
python
tools = {
"aoi_detect": detect_defects,
"xml_config_read": read_xml,
"xml_config_write": write_xml,
}
AOI 检测工具需要传入图片路径、检测模式(传统算法 / 深度学习 / 混合)、canny 高低阈值、CLAHE clip limit、最小缺陷面积......总共 9 个参数。LLM 有时候传 canny_low="50"(字符串),有时候漏传必填参数,有时候把不存在的 mode 传进来。
在通用 Agent 场景下,参数传错了大不了返回个错误信息让 LLM 重试。但在设备控制场景下,如果 LLM 传了错误的 XML 参数直接写入了上位机配置,下一次检测可能整批漏检。 这比 API 报错严重得多。
所以工具注册中心的第一个需求不是"方便注册",是在参数触及设备之前把它拦下来。
二、参数校验不是 nice-to-have,是安全边界
每个工具的参数在注册时就声明了完整约束:
python
@dataclass
class ToolParameter:
name: str
type: str # string / number / boolean / array
description: str
required: bool = True
default: Any = None
enum: list[str] | None = None # 枚举白名单
执行时的校验顺序是精心设计的:
lua
execute("xml_config_write", **kwargs)
│
├── 1. 工具是否已注册?
├── 2. 工具是否被禁用?
├── 3. 参数校验(必填 / 类型 / 枚举白名单 / 默认值补全)
├── 4. 速率限制检查(被限流不会浪费在校验失败的调用上)
│
└── 5. 通过,执行函数
第 4 步的顺序是关键。 如果先查速率限制再校验参数,那参数错误会消耗限流令牌------LLM 被限流后看到的错误是"参数非法",它会以为工具不可用,换一个工具再试,又被限流。这个恶性循环在生产中非常致命。
校验同时做了自动补全 :如果 LLM 没传可选参数,直接填入默认值。比如 AOI 检测的 clahe_clip 默认 2.0,LLM 不需要每次显式传------这对 LLM 的 token 消耗也有好处,prompt 不用列出全部 9 个参数。
三、统一的返回格式:让 LLM 不需要理解异常
不管工具内部发生什么------文件不存在、ONNX 模型加载失败、XML 格式校验不通过------调用方永远拿到:
python
{
"success": True | False,
"result": Any,
"error": str | None,
"latency": 0.123
}
这件事看起来极其简单,但它解决了一个实际的工程问题:LLM 的判断逻辑不需要针对每种工具写不同的错误处理 。它看到 success == False 和 error 字段,就能决定下一步------换参数重试、换工具、还是告诉用户"当前设备状态不支持此操作"。
对 AOI 场景来说,这个统一格式还有一个好处:所有工具调用的结果都可以被记录和审计。如果某次 XML 改写导致了检测质量下降,你能回溯到具体哪个工具调用、传了什么参数、耗时多久。
四、从 ToolRegistry 到 LangGraph:适配比注册更复杂
LangGraph 的 React Agent 期望的是 StructuredTool 对象,需要 Pydantic schema。如果每个工具手写一个 Pydantic 类,22 个工具就是 22 个类------维护成本不可接受。
Adapter 做的事是运行时动态生成:
python
def _make_pydantic_schema(params):
fields = {}
for p in params:
field_type = {"string": str, "number": float, "integer": int}.get(p.type, str)
fields[p.name] = (field_type, Field(description=p.description, default=p.default))
return create_model("DynamicArgs", **fields)
没有手写任何 Pydantic 类。工具注册时声明参数元数据 → Adapter 自动生成 schema → LangGraph 直接使用。新增一个工具不需要改 Adapter 的任何代码。
五、单例和测试:写测试比写功能花的时间更多
工具注册中心是单例------Agent 启动后全局只有一个。但单例在测试中是个灾难:测试 A 注册了 5 个工具,测试 B 又注册了 3 个,状态互相污染。
解决方案是双重检查锁 + 显式 reset:
python
class ToolRegistry:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@classmethod
def reset(cls):
"""仅用于测试"""
cls._instance = None
每个测试文件开头调用 ToolRegistry.reset(),保证干净的状态。这件小事花了跟写功能差不多的时间,但没有它,196 个测试根本跑不稳。
六、和通用 Agent 框架的真正区别
LangChain / CrewAI 的工具注册解决的是"怎么让 LLM 调用工具"。我的场景多了一层:这些工具的背后是真实的工业设备。
所以多了几样东西:
- 参数的白名单约束 ------不是建议,是硬限制。
canny_low的范围是 [0, 255],传 300 就是拒绝。 - 工具的分级禁用------设备维护时可以单独禁 AOI 检测工具,不影响 Agent 的其他功能。
- 每个调用的统计追踪------不是为了计费,是为了追溯质量问题。
这些不是架构上的创新,只是工程上必须做的事。但恰恰是这些"必须做的事",让一个个人项目看起来像生产系统。
这个项目目前还处于早期------AOI 闭环调参还在验证阶段,很多边界情况还没覆盖。但工具注册中心这一层的设计,是后面所有功能的底座。下一篇聊另一个底座:在产线上跑 Agent,LLM API 抖一下真的会停线。怎么让 LLM 调用做到工业级的可靠性?