最近在折腾 agent skills 架构设计,说句实话,最初接触这个概念时我以为又是套了一层皮的“插件系统”,等真正把一个多技能智能体从原型推到可维护状态之后,才意识到这里面的门道比想象中深不少。简单讲,skills 架构解决的是“给智能体装技能”这件事的标准化问题,让模型在需要时能准确找到某个能力、调用某个工具、拿到结构化结果,而不是把所有逻辑都塞进一个巨大的提示词或者一堆散落的函数里。如果你正在做 agent 应用,或者想把现有的工具函数整理成可复用的技能体系,这篇内容基本覆盖了从设计思路到落地实现的完整路径。
1. 先看懂 agent skills 到底在解决什么问题
我们先把问题摆到台面上。一个智能体最开始只有两三个内置功能时,直接在代码里写if 意图 == "查天气"就够了。但一旦功能多起来,比如你的 agent 需要查日程、发邮件、写周报、审批流程、调内部 API、搜索知识库、处理报表,事情就开始失控。
我见过最典型的失控场景是这样:所有功能以函数形式暴露给模型,一个函数列表里排了四五十个条目,每个条目一大段描述。模型在做工具选择时,面对海量候选会变得极其不稳定——有时候明明该用“查询日程”却调了“创建日程”,有时候该走 A 接口却选了 B 接口。这不是模型笨,而是候选太多之后,描述重叠、边界模糊,准确率自然会下降。
这时候你自然会想到分组、加前缀、优化描述。但如果你再往前想一步,就会发现真正需要的是一套“技能基础设施”:把每个能力封装成独立单元,附带清晰的元数据、输入输出契约、权限声明和触发条件,然后由一个注册中心和调度器统一管理。这就是 agent skills 架构设计要解决的核心问题。
它本质上是把“函数列表”升级为“技能列表”,再把“技能列表”升级为“带路由规则的技能注册表”。每个 skill 不仅包含实现代码,还包含一堆机器可读的说明信息。模型不是一次性看到所有技能,而是先根据用户输入做一次粗筛,再在候选技能里做精确选择。这有点像你手机里的应用商店:你不会把几万个 App 全部平铺在桌面上,而是需要时搜索、按分类浏览、看评分描述,再决定下载哪个。
落到实际价值上,这套架构能带来几个直接收益:
- 技能可独立开发、独立测试、独立上线,不污染主流程代码。
- 模型收到的候选列表大幅缩短,工具选择的准确率肉眼可见地提升。
- 技能之间的边界清晰,一个技能出问题不会拖垮整个 agent。
- 新增能力不需要改调度主逻辑,只需要往注册中心里登记一个新 skill。
- 权限和依赖可以集中管理,而不是散落在各处。
这里需要澄清一个容易混淆的点:skills 不是新的算法,也不是某种神秘框架,更多的是一种工程组织模式。它把声明、路由、执行三层拆开,让每层各司其职。理解了这一点,后面所有设计决策都会变得顺理成章。
2. 从零搭建一套 agent skills 架构:目录、元数据与调用约定
想明白要解决什么问题之后,我们就可以动手设计了。在我看来,一套能落地的 skills 架构至少要包含三个基础模块:技能注册表(Registry)、技能调度器(Dispatcher)、技能执行器(Executor)。而在这三个模块之上,最关键的其实是两个前置设计:目录规范和元数据规范。
2.1 目录与命名规范:先定边界再写代码
我先讲目录。没有目录规范,技能一旦多起来,代码会乱成一锅粥。我推荐一个很朴素的约定:每个技能独立目录,拥有完整的生命周期,目录名即技能名。
skills/ calendar/ list_events/ SKILL.md main.py schema.json create_event/ SKILL.md main.py schema.json mail/ send_email/ SKILL.md main.py schema.json report/ generate_weekly_report/ SKILL.md main.py schema.json这个结构有几个好处。第一,每个技能自带“岗位说明书”,也就是 SKILL.md 和 schema.json,不依赖他人讲解也能直接接入。第二,按领域分组之后,调度器可以做领域级粗筛,比如识别到用户输入涉及“发邮件”就直接进入 mail 域,而不是遍历所有技能。第三,版本管理非常方便,每个目录可以单独评审、单独发布。
我还习惯在每个技能目录里约定一个固定的生命周期状态,写在 SKILL.md 头部:
version: 1.2.0 status: active # draft | active | deprecated owner: agent-corestatus字段特别实用。开发中的技能标成draft,调度器默认不会对线上模型开放;出问题的技能标成deprecated,可以快速下线而不需要改代码。这个小习惯帮我避免了好几次“半成品技能被模型选中”的尴尬事故。
2.2 元数据设计:让 agent 知道什么时候该用它
目录只是骨架,元数据才是灵魂。一个 skill 能不能被准确调用,基本取决于它的元数据写得怎么样。我把元数据拆成两个文件:SKILL.md给人看、给模型做语义理解;schema.json给程序做参数和返回值的强校验。
SKILL.md里最核心的部分是描述和触发条件。描述不能写“这是一个日程查询功能”,这种太弱了。合格的描述要包含三个要素:这个技能做什么、在什么场景下使用、什么情况下不要用。我用过一段描述,你可以感受一下差异:
name: list_events description: > 查询用户在指定日期范围内的日程安排,返回事件列表。 适用于用户询问“我明天有什么安排”“下周有哪些会议”“某天是否有空”等场景。 当用户想要创建、修改或删除日程时,不要使用本技能,应该交给 create_event 或 update_event。 triggers: keywords: - 日程 - 安排 - 会议 - 有空 - 排期 intent_patterns: - "查询.*日程" - ".*(明天|今天|下周|下周[一二三四五六日]).*(安排|会议|有空)" input_schema: schema.json你可能会问,description和triggers是不是重复了?不完全重复。我在实际设计里把两者做了分工:description是给模型看的,当模型在做候选排序时,它依赖的是文本语义;triggers是给规则引擎用的,适合做第一轮的快速过滤。比如用户说“我下周的会多不多”,关键词“下周”“会”就能直接命中,根本不需要模型参与。这种混合策略能减少大量不必要的模型调用,省钱、省延迟。
schema.json则是严格的输入契约。我还是以查询日程为例:
{ "type": "object", "properties": { "start_date": { "type": "string", "description": "查询起始日期,格式 YYYY-MM-DD", "format": "date" }, "end_date": { "type": "string", "description": "查询结束日期,格式 YYYY-MM-DD,含当天", "format": "date" }, "calendar_ids": { "type": "array", "items": { "type": "string" }, "description": "可选,指定要查询的日历 ID;不传则查全部" } }, "required": ["start_date", "end_date"] }参数定义得越严格,后续的校验和排错就越轻松。尤其建议把description写到每个参数字段里,因为当模型需要自己填参数时,它完全依赖这些描述来理解“这个参数到底该填什么”。
2.3 调用协议与错误返回:别把异常当成“没这个技能”
执行阶段最容易被忽略的是返回值约定。我强烈建议所有技能统一返回一个结构化信封,而不是直接返回原始文本或直接抛异常。我自己用的协议长这样:
{ "status": "success", "skill": "list_events", "data": { "events": [ { "id": "evt_001", "title": "产品评审", "start": "2025-01-15T14:00:00+08:00", "end": "2025-01-15T15:00:00+08:00" } ], "total": 1 }, "meta": { "cost_ms": 356, "truncated": false } }出错时返回统一错误结构:
{ "status": "error", "skill": "list_events", "error": { "code": "SKILL_INVALID_PARAMS", "message": "start_date 不能晚于 end_date", "details": {} } }为什么这么强调统一协议?因为 agent 主流程在拿到结果后要做两件事:一是判断这次调用成功没有,二是决定怎么向用户表达。如果每个技能各返回各的格式,有的直接抛异常,有的返回字符串,有的返回None,主流程就要写一大堆分支去适配,还很容出错。
统一协议还有一个隐藏好处:可以接通用的后处理逻辑。比如超时、截断、重试、日志上报,全部可以做成中间件,不用每个技能各自实现一遍。
3. 实操过程与核心环节实现
设计好规范和协议之后,真正写起代码来其实并不复杂。我来拆一套我实际用过的最小内核实现,去掉业务噪音之后,核心逻辑就三块。
3.1 最小内核实现:注册中心、调度器与执行器
我先写注册中心。它的职责很简单:启动时扫描技能目录,把每个技能的元数据加载到内存,并提供查询接口。
import json from pathlib import Path from typing import Any, Dict, List, Optional class SkillRegistry: def __init__(self, skills_root: str): self.skills_root = Path(skills_root) self._skills: Dict[str, Dict[str, Any]] = {} def load_all(self) -> None: for skill_dir in self.skills_root.glob("*/*"): skill_md = skill_dir / "SKILL.md" schema_json = skill_dir / "schema.json" if not skill_md.exists() or not schema_json.exists(): continue meta = self._parse_skill_md(skill_md) meta["input_schema"] = json.loads(schema_json.read_text(encoding="utf-8")) meta["entry"] = skill_dir / "main.py" self._skills[meta["name"]] = meta def get_by_name(self, name: str) -> Optional[Dict[str, Any]]: return self._skills.get(name) def search(self, query: str, domain: Optional[str] = None) -> List[Dict[str, Any]]: candidates = [] for meta in self._skills.values(): if meta.get("status") != "active": continue if domain and meta.get("domain") != domain: continue score = self._match_score(query, meta) if score > 0: candidates.append((score, meta)) candidates.sort(key=lambda x: x[0], reverse=True) return [meta for _, meta in candidates] def _match_score(self, query: str, meta: Dict[str, Any]) -> float: # 简易关键字打分,生产环境可以换成向量召回 score = 0.0 for kw in meta.get("triggers", {}).get("keywords", []): if kw in query: score += 1.0 return score这个类做了三件事:加载目录、按名字查技能、按关键字做粗筛。注意我特意留了一个_match_score函数,生产环境里你可以把它替换成 embedding 向量检索,或者调用一个小模型做粗排。这里的关键设计是:注册中心只负责“候选召回”,不负责“精确决策”。
接着是调度器。调度器拿用户输入,先做粗筛,再决定最终调哪个技能。我推荐的方式是先把候选列表压缩到 3 到 5 个,再让模型做精细选择。这样模型既不会面对 50 个选项,也不会因为候选太少而误选。
class SkillDispatcher: def __init__(self, registry: SkillRegistry): self.registry = registry def dispatch(self, user_input: str, llm_choose_fn) -> Optional[str]: candidates = self.registry.search(user_input) if not candidates: return None if len(candidates) == 1: return candidates[0]["name"] # 候选多于一个时,交给模型做语义匹配 return llm_choose_fn(user_input, candidates)llm_choose_fn是一个回调函数,由调用方注入。实际项目里它的实现就是一次 LLM 调用,把用户输入和候选技能的 name、description 拼进提示词,让模型返回最合适的那个技能名。这里我没有把 LLM 调用写死在调度器里,是为了方便测试——单测时可以注入一个固定返回的 fake 函数。
最后是执行器。执行器负责加载 skill 的实现代码、校验参数、执行并捕获异常。
import importlib.util import traceback class SkillExecutor: def __init__(self, registry: SkillRegistry): self.registry = registry def execute(self, skill_name: str, params: Dict[str, Any]) -> Dict[str, Any]: meta = self.registry.get_by_name(skill_name) if not meta: return {"status": "error", "skill": skill_name, "error": {"code": "SKILL_NOT_FOUND", "message": f"skill {skill_name} not found"}} errors = self._validate_params(params, meta.get("input_schema", {})) if errors: return {"status": "error", "skill": skill_name, "error": {"code": "SKILL_INVALID_PARAMS", "message": "; ".join(errors)}} try: module = self._load_module(meta["entry"]) result = module.run(params) return {"status": "success", "skill": skill_name, "data": result} except Exception as exc: return {"status": "error", "skill": skill_name, "error": {"code": "SKILL_RUNTIME_ERROR", "message": str(exc), "details": {"traceback": traceback.format_exc()}}} def _validate_params(self, params: Dict[str, Any], schema: Dict[str, Any]) -> List[str]: # 这里用 JSON Schema 校验库会更严谨,演示版做必填检查 errors = [] for field in schema.get("required", []): if field not in params: errors.append(f"missing required param: {field}") return errors def _load_module(self, entry: Path): spec = importlib.util.spec_from_file_location(entry.stem, entry) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module执行器里有几个点要说明。_validate_params在演示代码里只做了必填检查,真实项目务必引入 JSON Schema 的校验库,否则参数类型错误会在运行中才暴露。_load_module动态加载每个技能的main.py,要求每个技能文件必须实现一个run(params)函数,这是我们对技能实现者的统一约束。
三块代码拼起来,一个最基础的 agent skills 架构就已经能跑通了。技能开发者只需要在目录里加文件,主流程代码一行都不用改。
3.2 一次完整调用的链路拆解
把代码串起来看一次实际调用。假设用户说:“帮我看看明天下午有哪些空会议室。”
第一步,主流程拿到用户输入,先走调度器。注册中心里的全部技能缩成了几个领域,这个输入明显属于“会议室领域”,粗筛命中了search_available_rooms这一个候选。调度器一看候选数量是 1,直接返回技能名,不需要额外调用 LLM,省了一次模型请求。
第二步,执行器拿到技能名和参数。参数怎么来的?这里有两种做法:一种是由调度器或主流程调用 LLM 将自然语言转换为参数 JSON;另一种是由 skill 入口函数自己解析。我推荐前者,因为主流程统一负责“自然语言到结构化参数”的转换,技能本身只接收干净参数,实现会简单很多。
第三步,技能内部执行。search_available_rooms调用了内部会议室系统 API,拿到房间列表和空闲时间段,把结果整理成统一信封返回。主流程拿到{"status": "success", "data": {"rooms": [...]}},再交给 LLM 组织语言回答用户。
整个链路里,模型一共参与了两次:一次是参数提取,一次是结果转自然语言。技能匹配这一步基本没让模型做选择题,因为粗筛阶段已经把候压缩到极致。这就是这类架构节省 token、提升准确率的关键所在。
3.3 权限、依赖与并发:被忽视的三个细节
最小内核跑通之后,接下来就要面对生产环境的三座大山:权限、依赖、并发。
权限是所有 skill 架构里最容易被拖到后面处理的问题。你肯定不想“查询日程”的技能拥有“删除日程”的权限,更不想一个第三方技能能随便读环境变量里的数据库密码。我的做法是在SKILL.md里强制声明权限需求:
permissions: network: allow: - "api.internal-rooms.example.com" deny: [] env: allow: - "ROOM_SERVICE_API_KEY" filesystem: read: allow: ["/tmp"] write: allow: []然后在执行时用统一的权限中间件做拦截。凡是代码里出现的对外请求域名、环境变量名、文件路径,都必须和声明匹配,不匹配直接拒绝。这个机制看起来死板,但能防止很多人为疏忽。
依赖管理也是大坑。一个技能可能依赖另一个技能,比如“发送会议邀请”依赖“查询参会人空闲时间”。要处理这种情况,我会在元数据里加一块:
depends_on: - list_events - search_contacts注册中心加载完成后,会对全量技能做一次拓扑排序检测。如果 A 依赖 B、B 又依赖 A,直接报错拒绝启动,避免运行到一半才发现循环依赖。另外,技能内部的 Python 第三方库依赖也不要全局安装,最好每个技能虚拟环境独立或者用依赖锁文件,防止两个技能需要同一个库的不同版本。
并发控制同样值得提前设计。多个技能同时执行时,不能用一条线程跑到底,否则一个技能里的阻塞调用会卡死整个 agent。我习惯给每个 skill 一个独立线程池,并设置最大并发数,同时加大全局超时控制。超时后的处理要融合到统一协议里:
{ "status": "error", "skill": "search_available_rooms", "error": { "code": "SKILL_TIMEOUT", "message": "execution exceeded 5000ms limit" } }这样主流程能够区分“技能不存在”“参数错误”“运行超时”“依赖服务挂了”等不同错误类型,分别给出不同的用户提示和重试策略。
4. 技能编排:复杂任务的“串”与“并”
单技能跑通只是第一步,真实业务场景里往往需要多个技能配合完成一个复杂任务。比如用户说“帮我安排明天下午的部门会议,并订一间能容纳十人的会议室,会前把资料发给所有人”。这句话至少涉及创建日程、查会议室、查联系人、发邮件四个技能。这时候就需要编排层。
4.1 顺序编排:前一个的输出是后一个的输入
最简单的编排是顺序执行。我习惯用一个轻量级 DSL 来描述流程,而不是在代码里写死。下面是一段 YAML 编排定义:
workflow: arrange_meeting steps: - id: fetch_participants skill: search_contacts params: department: "{{user.department}}" - id: find_room skill: search_available_rooms params: capacity: 10 time: "{{event.time}}" - id: create_event skill: create_event params: title: "{{user.title}}" participants: "{{steps.fetch_participants.data.contacts}}" room: "{{steps.find_room.data.room_id}}" time: "{{event.time}}"每个步骤声明依赖前一个步骤的输出,编排引擎按依赖顺序执行,前一步失败就终止整个流程,返回具体是哪个步骤出错。这个 DSL 的好处是流程可以做成可视化配置,换一个场景只需要改 YAML,不用重新发版。
写这套 DSL 时要注意“输入来源”的清晰性。我自己踩过一个坑:步骤 A 的输出字段是data.contacts,步骤 B 却读成了data.users,最后运行时才发现字段对不上。现在我在 schema 里会定义每一步的输出结构,并在编排加载时做静态校验,尽量减少这类低级错误。
4.2 并行编排:能同时做的绝不串着等
编排里还有一种常见需求:多个相互独立的步骤可以并行执行。比如“查会议室”和“查参会人空档”互不依赖,串行执行白白浪费时间。我在 DSL 里加了一个parallel节点:
workflow: arrange_meeting_v2 steps: - id: prepare parallel: - id: fetch_participants skill: search_contacts params: department: "{{user.department}}" - id: find_room skill: search_available_rooms params: capacity: 10 time: "{{event.time}}" - id: check_calendar skill: list_events params: start_date: "{{event.date}}" end_date: "{{event.date}}" - id: create_event skill: create_event params: title: "{{user.title}}" participants: "{{steps.prepare.fetch_participants.data.contacts}}" room: "{{steps.prepare.find_room.data.room_id}}" time: "{{event.time}}"并行节点里每个分支的变量访问要用steps.prepare.<branch_id>前缀,避免命名冲突。我在实现并行时用了asyncio.gather,同时给每个分支设置了独立超时,一个分支超时不会影响其他分支继续执行,但最终主流程会拿超时分支的错误信息。
4.3 条件路由:根据分支结果决定下一步
第三种常用编排模式是条件路由。比如“如果上午没有空闲会议室,就自动改为下午并再查一次;如果下午也没有,就把错误返回给用户”。我用一个switch节点表达:
- id: room_retry switch: - condition: "{{steps.find_room.data.total}} == 0" next: find_room_next_day - condition: "{{steps.find_room.data.total}} > 0" next: create_event default_next: report_no_room条件表达式走一段表达式引擎,不直接写 Python 代码,因为 DSL 未来可能暴露给非研发人员配置。条件里只允许访问步骤输出、用户上下文和固定的常量。在实现时,我限定表达式白名单,不允许任意代码执行,这是安全底线。
4.4 从链式编排到图编排:守住无环底线
当编排越来越复杂,YAML 看起来已经不是简单的“链”,而是一张图。这时候我建议你至少在内存里做一次有向无环图检查。方法不复杂,把所有步骤看成一个有向图,节点是 skill 调用,边是依赖关系,然后用拓扑排序检测是否存在环。如果存在环,直接报错并提示是哪个步骤组合导致的循环引用。
很多人会问:既然都图编排了,为什么不直接用现成的工作流引擎?我的体会是,现成引擎确实功能全,但跟 agent 的对话场景不太匹配。agent 编排是动态生成的流程,往往依赖用户当轮输入和中间结果,而不是静态固化的业务流。所以我在实现中采用了“半静态编排”:常见流程用 YAML 预置,异常分支和动态调整由模型在运行时修改编排参数,但修改范围被 DSL 严格约束。这样既有确定性,又保留了灵活性。
5. 常见问题与排查技巧实录
这套架构从开发到上线,我大概积累了不少值得记录的排查经验。这里整理成一个速查表,每一条都是真实踩过的坑。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 用户输入明确但技能没被选中 | 元数据 trigger 关键词覆盖不全,或 description 描述与真实场景偏差太大 | 打印注册中心粗筛候选列表,看每个命中的分数;用真实用户语料回放测试 |
| 技能被选中但执行时参数频繁缺失 | schema 描述不够明确,或者参数示例不足 | 在 schema.json 的每个字段增加examples字段,LLM 提取参数时会参考 |
| 返回结果太大导致模型上下文爆炸 | skill 返回了完整列表而未做分页 | 在协议中增加分页字段,限制单次page_size,主流程需要时再请求下一页 |
| 技能执行超时但主流程无感 | 外部服务接口慢,skill 自身没有设置超时 | 在统一中间件层添加全局超时,并在 skill 输出中附加cost_ms字段监控 |
| 多个技能间接产生循环依赖 | 编排 DSL 配置错误 | 启动阶段做拓扑排序检测,打印循环链上的技能名 |
| 半成品技能被模型误选 | 技能已经加载到注册表但逻辑未完成 | 用status: draft标记,调度器粗筛时直接过滤非 active 技能 |
| 技能内部抛出的异常信息不够明确 | skill 开发者直接抛了通用 Exception | 约定所有技能异常必须带错误码,并在统一协议里透传error.code |
| 同一个外部 API 被多个技能重复调用 | 缺少共享数据层,出现重复查询 | 在编排层增加简单的结果缓存,按输入参数哈希存储 |
除了表格里的内容,我再分享两条独家心得。
第一条:一定要给 skill 调用链路加一层可观测日志。不是只记“调用了哪个技能”,而是记录完整链路:用户输入原文、粗筛候选列表及其得分、最终选中的技能名、提取到的参数 JSON、技能返回值、耗时、错误信息。把这七件事打成一个日志对象。有了这层日志,绝大多数问题都能在几分钟内定位原因,否则就只能靠抽查复现瞎猜。
第二条:开发阶段一定要做“技能回放测试”。我建议把线上真实用户输入脱敏后收集成测试语料库,每次新加一个技能,就跑一遍全量语料,看是否有“旧技能被挤掉”“新技能被误选”的情况。这个回放测试看起来笨重,但它是防止技能数量膨胀后系统准确率滑坡的最有效手段。我在一次技能从 8 个增加到 23 个时,靠这套回放测试发现了 3 个原本会被误选的场景,提前把描述修正了。
6. 从 v1 到演进:这条架构路径还能怎么走
这套架构跑到稳定之后,我发现它留出了几条很自然的演化路径,如果你正在设计自己的版本,可以提前留好扩展点。
第一,技能库可以往“多角色共享”方向走。同一个组织内,不同角色的 agent(销售助手、HR 助手、研发助手)不需要各自维护一套技能,而是共享同一个注册中心,只是在调度层增加角色过滤条件。也就是说,一个技能在创建时声明适用于哪些角色,调度器在做粗筛时直接按当前会话角色过滤掉不可见技能。这样技能的管理成本和复用效率都会明显改善。
第二,可以考虑技能热更新机制。当注册中心支持从远程配置拉取元数据时,你可以做到某个技能描述更新后立刻生效,不用重新发布整个 agent 应用。我当时是给技能文件加了一个版本字段和远程仓库同步机制,SKILL.md 变化后自动 reload 对应技能。要注意的是,热更新只更新元数据和实现文件,不要在执行过程中改动注册表,否则会出现并发读写的奇怪问题,稳妥做法是每次更新先构建一份全量快照,再原子切换。
第三,可观测性还能继续加深。除了调用日志,我会建议给每个技能增加“置信度”字段,由调度器在候选排序后记录自己对每个候选的把握。汇总这些置信度之后,你就能发现哪些技能描述写得模糊、哪些技能经常和同类技能竞争。这是一个持续优化的入口——与其凭感觉反复改描述,不如让数据告诉你该改哪里。
第四,安全方面还值得再做一层。技能执行时可以考虑放进受限环境,比如容器或子进程沙箱,限制 CPU、内存、网络访问。尤其是当你准备引入第三方技能开发者生态时,沙箱不是可选项,而是必选项。我见过很多内部 demo 因为觉得“都是自己写的代码”就不做隔离,结果一次误操作删了共享目录里的测试数据,得不偿失。
最后说说我对这套架构的总体判断。它不是银弹,解决不了模型能力不足的问题;它更像一个管理框架,让智能体在多能力场景下保持可控。我踩过最深的坑,是过早追求复杂——一开始就想做可视化编排、插件市场、动态热更新,结果基础元数据都没设计好,上线后天天救火。如果你的项目正处于起步阶段,我的建议很简单:先把注册、调度、执行三条链路跑明白,把元数据写清楚,把回放测试建起来。这四件事做完,你的 agent 就已经比大多数没有架构直接堆代码的项目稳出一条街了。至于更复杂的编排、沙箱、热更新,等业务规模逼到那一步再上也不迟。