写一个能跑的企业 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
2
3
4
# skills/skill_registry.py
def to_openai_tools(self) -> list[dict]:
"""所有 skill 统一 {args: string} schema——参数原样透传给现有 str→str handler。
LLM 靠 description 判断什么时候该传什么格式。"""

这样做有几个意外的好处:

  1. tools schema 短了一截。原来一个工具的 schema 占 30 行,现在 10 行。30 个工具就是 600 行 token 的差距,每次请求都省。
  2. 新加工具不用动协议层。写个 handler、写个 description,注册就完事了。
  3. 错误会更早暴露。LLM 把 SQL 当自然语言传也好、把多选写成单选也好,handler 第一行 parse 就报错,不会等到执行时才出事。

代价是 description 得写得够清楚——但 description 本来就是 LLM 路由的关键,写不清楚不管用什么 schema 都会乱。

Skill 注册器:约定大于配置

skills/ 目录下每个 skill 一个子目录:

1
2
3
skills/db_query/
├── skill.md # YAML frontmatter: name + description + parameters
└── handler.py # def handle(args: str) -> str

启动时 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
2
3
4
5
6
7
8
9
10
11
12
13
14
type: sql_pipeline
steps:
- id: locate
sql: |
SELECT id AS workflow_id, ... FROM workflow_base WHERE ...
- id: form_meta
sql: |
SELECT formid, tablename FROM workflow_bill
WHERE id = :workflow_id
inputs: { workflow_id: locate.workflow_id }
- id: instance_data
sql: |
SELECT * FROM {{ ident:tablename }} WHERE requestid = :requestid
inputs: { tablename: form_meta.tablename }

引擎按 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 创建页 → 按字段元数据逐个填 → 点保存。

遇到的坑(每一个都让我加班一两天):

  1. antd Select 吞 click:单选下拉的 DOM 是 wea-select 套 ant-select,直接点 wrapper 没用,必须点内部 .ant-select-selection。
  2. 多选 checkbox 真实点击事件被吞:点 label / .ant-checkbox-inner / input 都不会改 hidden input。最后只能直接改 DOM:手动加 class、写 hidden input value、dispatch change/input 事件。
  3. selectvalue 不是 0,1,2 连续整数:多选 / 下拉的真实存储值必须从 workflow_selectitem 表查,浏览器端按 group index 猜会写错。
  4. 隐藏字段卡 30s:E9 在条件可见 / 只读规则下是把容器整个 display:none,Playwright 默认 click() 等元素可见 30s。
  5. cookie 失效:浏览器登录态几小时就过期。
  6. antd 浮层污染:上一个字段的下拉浮层关不干净,下一个字段点开会命中错的浮层。

我加了一堆补丁,能跑了,但脆。10 个字段大概能填对 7 个。

某天我冷静下来想:这条路根本不该走。

E9 表单的 JS 公式确实是浏览器端跑的,但:

  • 简单流程根本没配公式(80% 场景)。报销、请假、合同续签,API 直发完全够用。
  • 复杂流程与其用 Playwright 痛苦地模拟浏览器,不如承认 Agent 做不动这部分,退到清单模式——把字段对照表(显示名 + 显示值)给用户,让用户自己去 OA 页面手填。

新架构:

1
2
3
4
5
6
7
8
9
10
LLM 调 create_workflow(workflowId, fields)
↓
查 workflow_billfield 拿主表字段元数据
↓
按 htmltype 解析用户值
↓
拼 mainData JSON → POST doCreateRequest
↓
成功 → 返回 requestid + 跳转链接
失败 → 退清单模式(markdown 表格 + 提示用户手填)

代码量直接砍了一半。_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 删干净的下午