写一个能跑的企业 Agent —— 一个月,30 个 skill,几条架构上的取舍
写一个能跑的企业 Agent —— 一个月,30 个 skill,几条架构上的取舍
起点:我想要一个能干活的 Agent
去年开始我陆续看了不少 Agent demo,做的事都很漂亮——会查天气、会订机票、会帮你算 GitHub commit 数。问题是这些 demo 跑的是公开 API 和合成数据。我想做一个能在我自己工作场景里真用得上的 Agent:每天打开它,问一些原本得自己写 SQL / 点十几下后台的问题,它能直接给我答案。
我们公司用泛微 E9,几千张表,上百个流程。这正好是个合适的”靶子”——边界清晰、数据足够多、问题足够杂。我没想做通用 Agent,就想做一个专用、稳定、长期能维护的东西。
写到现在大概一个月,30 个 skill,覆盖了我们公司日常 OA 查询的绝大部分场景。这篇主要谈架构上的几个取舍——哪些是我一开始就想对的,哪些是我后来才搞明白的,哪些是我撞墙之后才学到的。
为什么不用 LangChain / LangGraph
第一个决定是不用框架。
我知道这听起来像是 NIH。但我开始动手的时候,就想清楚一件事:Agent 这个东西最终拼的是 prompt 和工具协议,框架抽象那一层我每次都得拆开看,否则没办法做精准调试。LangChain 有它合理的地方,但它把”prompt 长什么样”、”工具调用是什么协议”、”tool result 怎么塞回 message 历史”这些都包起来了。一旦不符合我的预期,我宁可自己写 200 行 Python,也不想花一下午 trace 它的源码。
后来 OpenAI 的 Function Calling 协议成熟了,我就直接用 native tool_calls,比早期那种用正则解析 Action: xxx 文本可靠太多。整个 ReAct 主循环(agent/runner.py)其实就是个普通 while 循环:调模型 → 拿到 tool_calls → 串行执行 → 把 role: tool 结果塞回 messages → 再调模型,直到没有 tool_calls 为止。
这个决定我现在仍然觉得对。每次我想调一个细节——比如”tool result 太长要不要截断”、”thought 部分要不要让模型显式输出”、”system prompt 里 memory / experience 怎么注入”——都是改自己代码 5 行的事情。
Skill 协议的极简化:所有工具一个 args: string 参数
第二个决定是在 schema 上做减法。
OpenAI 的 function calling 是允许你给每个工具定义结构化参数的:workflowId: number、fields: object、requestName: string。我一开始也是这么写的。
写了几个工具之后我发现一个事:让 LLM 严格按 JSON schema 填参数,比让它写一段自由文本更难。它会漏 required 字段、会塞错类型、会自作主张加你没定义的字段。每次出错你还得在 handler 里写一堆兜底代码。
我把所有工具改成同一个签名:
1 | def handle(args: str) -> str: ... |
tools 数组里所有工具都只声明一个 args: string 参数,LLM 根据 description 判断该传什么——自然语言、JSON、SQL、表达式、文件名,handler 内部自己 json.loads 或者别的解析。
1 | # skills/skill_registry.py |
这样做有几个意外的好处:
- tools schema 短了一截。原来一个工具的 schema 占 30 行,现在 10 行。30 个工具就是 600 行 token 的差距,每次请求都省。
- 新加工具不用动协议层。写个 handler、写个 description,注册就完事了。
- 错误会更早暴露。LLM 把 SQL 当自然语言传也好、把多选写成单选也好,handler 第一行 parse 就报错,不会等到执行时才出事。
代价是 description 得写得够清楚——但 description 本来就是 LLM 路由的关键,写不清楚不管用什么 schema 都会乱。
Skill 注册器:约定大于配置
skills/ 目录下每个 skill 一个子目录:
1 | skills/db_query/ |
启动时 auto_discover() 递归扫描,全自动注册。新加 skill 不需要改任何中央配置文件,写完就有。这种约定大于配置的写法对我个人项目特别友好——一个月里我从 0 写到 30 个 skill,中间没改过注册器代码。
ReAct 跑久了的那个问题:很多链路根本是确定的
跑了一两周以后我注意到,有些查询每次的链路都一样。
举个例子,”分析某流程的表单结构 + 实例字段内容”这个需求,每次 ReAct 都要走十几步:
- 先
db_query查workflow_base拿workflow_id - 再
db_query查workflow_bill拿formid和tablename - 再
db_schema查物理主表的字段 - 再
db_query查workflow_billfield拿htmltype - 再
db_query查workflow_selectitem拿下拉选项字典 - 再
db_query查那条实例记录 - 最后按
htmltype翻译显示值
每一步都是确定的,但 LLM 每次都要重新推理这十几步。Token 烧得心疼,延迟也长,还容易在中间某一步走神。
这是 ReAct 的一个本质问题:它把所有规划压力都丢给 LLM。对于确定性链路,规划不该是运行时干的事。
第二个决定:Compound Skill —— 把确定性链路固化成 YAML
我加了一个东西叫 Compound Skill。一份 YAML 描述 SQL 流水线:
1 | type: sql_pipeline |
引擎按 step 顺序执行,后一步可以引用前一步结果。模板有三种语法:
:param走 DB 驱动绑定 → 防 SQL 注入{{ ident:name }}仅允许白名单正则^[A-Za-z_][A-Za-z0-9_]*$→ 用于动态表名(譬如formtable_main_{{ formid }})- 直接
{{ xxx }}字面值替换被引擎拒绝 → 强制走前两种
形态有三种:
| 形态 | 文件 | 用途 |
|---|---|---|
| Atomic | skill.md + handler.py |
纯 Python 逻辑 |
| 纯 Compound | skill.yaml |
纯 SQL 流水线 |
| 混合 Compound | skill.md + handler.py + skill.yaml |
SQL 流水线 + Python 后处理 |
效果立竿见影。原来要十几步 ReAct 的 oa_form_analyze,现在 1 次 tool_call 搞定。我陆续把 12 个 OA 平台级查询全做成了 Compound:流程运行态、流程定义节点、待办、督办、字段联动、HR 档案、HR 自定义字段、资产台账、角色查询、行级权限的建模表数据查询……
回过头看 Compound Skill 是这个项目里最重要的一个架构决定。它把 Agent 设计从”全靠 LLM 推理”挪到了”框架内固化、不确定的边角才让 LLM 处理”。Token 消耗下降是一方面,更关键的是链路稳定性——固化的 SQL 不会突然走错,只要数据库在,结果就在。
一个反例:写操作我撞了三次墙才认怂
读的能力都顺。写就开始痛苦了。
E9 提供 POST /api/workflow/paService/doCreateRequest 创建流程实例。直接 API 写,简单流程一发就过。但 E9 表单设计器允许配 JS 公式 + 字段联动,这些在浏览器里跑的——API 直发会跳过,字段值不正确。
我转向 Playwright:用浏览器打开 E9 创建页 → 按字段元数据逐个填 → 点保存。
遇到的坑(每一个都让我加班一两天):
- antd Select 吞 click:单选下拉的 DOM 是
wea-select套ant-select,直接点 wrapper 没用,必须点内部.ant-select-selection。 - 多选 checkbox 真实点击事件被吞:点 label /
.ant-checkbox-inner/input都不会改 hidden input。最后只能直接改 DOM:手动加 class、写 hidden input value、dispatch change/input 事件。 - selectvalue 不是 0,1,2 连续整数:多选 / 下拉的真实存储值必须从
workflow_selectitem表查,浏览器端按 group index 猜会写错。 - 隐藏字段卡 30s:E9 在条件可见 / 只读规则下是把容器整个
display:none,Playwright 默认click()等元素可见 30s。 - cookie 失效:浏览器登录态几小时就过期。
- antd 浮层污染:上一个字段的下拉浮层关不干净,下一个字段点开会命中错的浮层。
我加了一堆补丁,能跑了,但脆。10 个字段大概能填对 7 个。
某天我冷静下来想:这条路根本不该走。
E9 表单的 JS 公式确实是浏览器端跑的,但:
- 简单流程根本没配公式(80% 场景)。报销、请假、合同续签,API 直发完全够用。
- 复杂流程与其用 Playwright 痛苦地模拟浏览器,不如承认 Agent 做不动这部分,退到清单模式——把字段对照表(显示名 + 显示值)给用户,让用户自己去 OA 页面手填。
新架构:
1 | LLM 调 create_workflow(workflowId, fields) |
代码量直接砍了一半。_browser/ 目录、4 个 page_* skill、tools/setup_playwright.py、requirements.txt 里 100MB 的 playwright 依赖全删掉。
这一段经历最大的教训是:“我应该解决这个问题”和”这个问题值得解决”是两件事。Playwright 这条路不是技术上做不到,是技术上每个边角都得单独打补丁,维护成本无限。换个产品形态(API + 清单兜底),90% 用户体验不降反升。
写 Agent 项目的人很容易掉到一个陷阱里:把 Agent 能力上限当成产品成功标准。其实更重要的是 Agent 在能力边界外怎么优雅地退化。退到一份清晰的清单 + “请你手填”,比硬撑一个 70% 成功率高级。
把硬规则当代码写
Agent 项目跑久了我有一个体会:LLM 自由度太高,prompt 里的硬规则比 handler 代码还重要。LLM 会做你想都想不到的傻事。
举几条我在 prompts/react.md 写的硬规则:
用户可见输出规则:默认 LLM 会把 requestid: 12345、workflowId: 177、fieldname: lcbt 这种内部 id / 拼音字段名直接吐给用户。硬规则:
- 答复里禁止出现拼音字段名 / 表名 / 内部 id
- 节点 / 审批人场景只说”节点名 + 审批人姓名 + 部门”
- 多选 / 下拉只说显示值(”单月额度”),不说原始代码值
组织实体匹配去尾缀规则:用户说”xxxx部门 2023 年提交多少单”,LLM 会拼 WHERE departmentmark LIKE '%xxxx部门%' 命中 0 条。但库里 departmentmark 存的是”xxxx”——“部门”是用户口语里的类别后缀。硬规则:先用原串 LIKE → 0 条 → 剥离尾部通用后缀(部门 / 分部 / 公司 / 中心 / 组 / 岗位 / 角色)再 LIKE → 仍 0 条才告诉用户查不到。
字段缺失一律不阻止 create_workflow:LLM 太喜欢自己当守门员。”XX 字段必填但您没给 / 建议先补齐 / 请先确认”——但 create_workflow 走”保存草稿”语义,必填校验由 OA 服务端做。硬规则:用户给了的字段照拼,没给的不传,该调就调。服务端报错原文透给用户。
每条硬规则我都带 Why: 和 How to apply: 行。这两行不是装饰——半年后回看这些规则,没有 Why 你会忘记为什么要写它,遇到边界 case 也判断不了到底要不要遵守。我把它当代码写、像代码一样维护。
防数据污染:哪些 SQL 不该固化
sql_drafts/ 目录我用来放未固化的 SQL 草稿。但我有一条硬规则:业务流程主表名(formtable_main_<数字>)跨环境会变。测试库 formtable_main_36 可能是流程 A,生产库的 formtable_main_36 可能是流程 B。把这种表名固化进 skill 描述,换库就会错到看不出来。
只固化平台级元数据查询表(workflow_* / hrm* / cpt* 等),业务流程主表 / 建模表单 / modedatashare_<modeid> 一律不固化。需要查具体流程内容,走 oa_form_analyze 这种入参驱动的 skill。
这一条规则也是我在 CLAUDE.md(项目级提示词)里写得最长的一段。Agent 长期可维护性这件事,相当一部分取决于你愿不愿意拒绝那些短期看起来很方便的事。
几件副产品
围绕主链路顺手做的几个东西,事后看比预期更有用:
Vanna.ai 集成:自然语言转 SQL。每次 nl_query 成功跑通,把 (question, sql) 喂给 Vanna 训练集,越用越准。
经验日志(experience_log.md):ReAct 结束后,LLM 复盘 Thought 链——SQL 示例 → Vanna,领域知识 / 错误教训 → experience_log.md。这个东西现在的设计有个问题:写得勤快,但读得很少(不会进 system prompt),相当于一个只写不读的本子。下一步我想把它要么蒸馏进 db_domain.md、要么做成 RAG 入口。
评测 Harness:每次改 prompt / skill / 模型,跑一遍 test/run_evals.py 看 pass@1 是否退化。三层评分:结构分(skill 路由是否命中)/ 规则分(answer 是否暴露物理字段名)/ Judge 分(LLM 判事实性)。光改一个 system prompt 都能让原本 pass 的 case 退化——这层防退化网比想象中重要。
生产端 Trace:AGENT_TRACE=1 后每次会话的事件流(thought / action / observation / answer_delta)落到 workspace/traces/{date}/{session_id}.jsonl。出 bug 后能离线 replay。
几条主观感受
不要让 LLM 自由发挥工具调用的格式。统一 {args: string} schema、handler 内部解析。LLM 越自由错得越花。
Compound Skill 比想象中重要。一个项目里有多少链路是确定性的?答案多半是”很多”——意味着你应该把它们固化。把规划权从”每次运行时让 LLM 推”挪到”开发时一次写死”,是企业 Agent 最大的稳定性来源。
承认能力边界比硬扛更专业。Playwright 那条路是反例,清单兜底是正例。Agent 做不到的事,明确告诉用户做不到 + 给出替代方案,比硬撑一个 70% 成功率要好。
写 Agent 半数时间在写 prompt。Skill 实现快,prompt 调到稳定难。最后 prompts/react.md 比所有 handler 的代码量加起来还多。Prompt 是这个项目的真正源代码。
框架是给生产团队的,不是给个人项目的。我现在仍然觉得不上 LangChain 是对的——一个月、30 个 skill、600 行注册器 + 主循环代码,没有任何一处我读不懂的抽象。这个项目要是上来就拥抱 LangGraph,我大概不会写这么快、也不会改架构改这么彻底。
代码托管在公司内网。这阵子整理一份开源版本(去掉公司特定的 OA 配置 + 数据字典)放出来。如果你也在给某个企业系统做 Agent,希望这篇能帮你少走点弯路。
—— 写于一个把 Playwright 删干净的下午

