先把对象放轻一点

我们写软件时,太习惯从对象开始。

用户、订单、文档、任务、Agent、工具、记忆、工作流。名词一列出来,系统好像就有了骨架。数据库表跟着出现,类跟着出现,接口跟着出现。很多项目的第一版设计,就是一张名词表变成一组对象,再变成一堆 CRUD。

这种方法不是错的。没有对象,系统无法命名,也无法落地。问题在于,对象太容易给人一种稳定错觉:仿佛只要把字段列清楚,一个东西就被理解了。

真正让系统运转的,往往不是对象本身,而是对象之间允许发生什么变化。

一张订单不是因为有 idamountstatus 才成为订单。它之所以是订单,是因为它能从草稿进入确认,能被支付,能被取消,能被履约,能被审计。一个文档不是因为有标题和正文才进入业务系统。它要被提交、评审、批准、发布、撤回。一个 Agent 也不是因为有模型、提示词、工具列表就完成了定义。它要被验证、发布、执行、观察、评估、修订和退役。

对象只是入口。转换才是系统的生命线。

范畴论真正有用的地方

范畴论常常被讲得很玄。对象、态射、函子、自然变换、Monad,词一多,读者很快离开现场。

但把它压到工程层面,最有用的启发其实很朴素:先别急着问“有哪些东西”,先问“这些东西之间有哪些合法箭头”。

在范畴论里,一个范畴至少有对象和态射。态射可以组合。若存在 A -> BB -> C,就可以得到 A -> C。这听起来像数学基础课里的符号游戏,放到软件里却很硬。

它要求我们追问:

  • 这个状态能不能变到下一个状态?
  • 这个转换的输入输出是什么?
  • 它能和哪个转换继续组合?
  • 组合以后,系统承诺的不变量还在不在?
  • 失败以后落在哪个状态?
  • 谁能触发这个转换?
  • 证据在哪里留下?

一个系统如果只记录 status = published,却说不清它从哪里来、由谁批准、经过什么校验、能不能回滚,这个状态就不够诚实。它只是一个字符串,不是一个生命周期事实。

范畴论对软件工程的价值,不是让工程师把 Functor 写进每个类名,而是提醒我们:关系和转换比名词更稳定。

函数式编程是它的工程落点

函数式编程和这个视角天然靠得很近。

在函数式编程里,函数通常被看成 A -> B。程序不再首先是一组拥有内部状态的对象,而是一串可组合的转换。

比如一个业务输入可以被拆成:

TypeScript typescript
RawInput -> ParsedInput -> ValidatedInput -> Command -> Result -> Response

这不是为了显得优雅,而是为了把风险切开。

parse 只负责把原始输入变成结构化数据。validate 只负责把结构化数据变成合法命令。execute 才处理外部副作用。每一步都能单独测试,每一步都有明确边界。失败也不再是一团异常,而可以变成 ResultEitherOption 这类显式返回。

这里有一个关键变化:类型不只是字段说明,而是状态边界。

不要只写一个宽泛的 Order,再靠 status 字符串撑起全部生命周期。更好的做法是区分 DraftOrderValidatedOrderPaidOrderShippedOrder。然后用函数表达合法转换。

TypeScript typescript
validate: DraftOrder -> ValidatedOrder
pay: ValidatedOrder -> PaidOrder
ship: PaidOrder -> ShippedOrder

如果系统里没有 DraftOrder -> ShippedOrder 这条转换,非法路径就不应该轻易发生。

函数式编程的强处就在这里:它把“变化”从对象内部拎出来,变成可以组合、可以测试、可以约束的东西。

Monad 不神秘,它是在管理现实

很多人第一次听 Monad,会以为它是纯数学趣味。工程里看,Monad 处理的是一个很现实的问题:普通函数很好组合,但真实程序总带着上下文。

现实程序很少只是:

TypeScript typescript
A -> B

它更多是:

TypeScript typescript
A -> Result<B, Error>
A -> Promise<B>
A -> Option<B>
A -> Audited<B>
A -> WithPolicy<B>

也就是说,计算可能失败,可能为空,可能异步,可能带日志,可能依赖环境,可能需要权限。Monad 类抽象的实用意义,是让这些带上下文的计算仍然能够继续组合。

这对 Agent 编程尤其关键。

一个 Agent 的运行绝不是:

TypeScript typescript
Input -> Output

它更像:

TypeScript typescript
Context + Input -> Audited<Result<Output, Failure>>

这里的 Context 包含身份、权限、知识范围、工具集合、模型策略、预算、审批状态和历史记录。Audited 表示运行过程要留下痕迹。Result 表示成功和失败都要被表达。Failure 还要区分可重试、需补充信息、需人工审批、必须终止。

这不是抽象洁癖。没有这些结构,Agent 越会做事,系统越不知道它到底做了什么。

本体论不该只是一张名词表

把视角再往外推一步,就会碰到本体论。

传统本体建模很容易从“什么东西存在”开始。人、组织、合同、项目、任务、工具、文档。然后给它们加属性,再连关系。这个方法适合做词表、数据库和知识图谱的第一版,但它也有一个弱点:它把实体看得太早、太稳。

一个概念的意义,并不只来自内部字段。它还来自它能进入哪些关系,参与哪些过程,承受哪些约束,产生哪些证据。

“供应商”不是一个孤立名词。它意味着某种合同关系、交付责任、资质边界、风险评估和付款流程。“审批人”也不是一个用户标签。它意味着某种授权、责任、可追溯决策和后续审计。

所以更强的本体模型不只回答:

  • 有哪些实体?
  • 它们有哪些属性?
  • 它们属于什么类别?

还要回答:

  • 哪些关系合法?
  • 哪些状态可转换?
  • 哪些转换需要证据?
  • 哪些关系可以组合推出新关系?
  • 哪些推理不能发生?
  • 关系在哪个语境下才成立?

这就是关系本体论给工程建模的提醒:对象不是被取消了,而是从世界的中心退回到关系网络里的稳定位置。

Agent 不是一个大对象

现在回到 Agent。

很多系统会把 Agent 建模成一个大对象:

  • 模型
  • 提示词
  • 工具
  • 记忆
  • 配置
  • 知识库
  • 运行参数

这些都需要,但还不够。因为这只是静态切面。

真正的问题是:这个 Agent 当前处在哪个生命周期位置?它接下来能合法进入什么状态?转换条件是什么?谁能批准?失败后如何处理?运行证据在哪里?版本如何继承?什么时候必须停用?

从这个角度看,Agent 生命周期不是“创建一个 Agent,然后调用它”。它更像一张状态转换图。

一条简化链路大概是:

TypeScript typescript
Idea -> AgentDefinition -> TestableAgent -> AgentVersion -> AgentRun -> Trace -> Evaluation -> VersionDecision

每个箭头都不是装饰。

Idea -> AgentDefinition 表示把模糊需求变成角色、任务、边界和能力声明。AgentDefinition -> TestableAgent 表示它已经能进入受控测试。TestableAgent -> AgentVersion 表示它通过了某些发布条件,成为可引用版本。AgentVersion -> AgentRun 表示一次运行必须绑定具体版本,而不是绑定一个会漂移的配置。AgentRun -> Trace 表示执行过程留下可观察记录。Trace -> Evaluation 表示运行结果进入评估。Evaluation -> VersionDecision 表示继续发布、回滚、修订或退役。

如果这些状态被混在一个对象里,系统会很快失真。

生命周期的核心是状态诚实

Agent 生命周期最怕的不是状态多,而是状态假。

这些东西不能混在一起:

  • 草稿不等于已验证。
  • 已验证不等于已发布。
  • 已发布不等于正在真实执行。
  • 运行成功不等于质量合格。
  • 质量合格不等于可以生产使用。
  • dry-run 不等于真实副作用执行。
  • 一次成功不等于稳定能力。
  • 配置更新不等于新版本已经通过评估。

系统一旦把这些差别抹平,就会出现很危险的幻觉:界面上看起来 Agent 已经“能做”,实际只是某一步局部跑通。

状态诚实的意思是,每个状态都要有清楚的进入条件、退出条件、责任主体和证据。published 不该只是一个按钮结果。它应该意味着某个版本经过了定义、验证、评估、审批,并能在需要时被追溯。

这时,范畴论式的态射思维就变成了治理纪律:每一次状态变化都要被当成一条有类型的边。

版本定义身份,运行定义事实

Agent 的身份也需要分层。

一个 AgentDefinition 可以保持相对稳定。它描述角色、目标、边界和能力意图。AgentVersion 则应该绑定具体提示词、工具权限、模型策略、知识范围、评估结果和发布时间。AgentRun 再绑定一次具体输入、上下文、工具调用、模型调用和输出。

这三个层次不能混。

如果修改了工具权限,通常应该产生新版本。如果换了模型策略,也应该触发重新评估。如果只是一次运行输入不同,那是新的 run,不是新的 definition。把这些层次分开,系统才能回答几个基本问题:

  • 这个结果是哪个版本产生的?
  • 当时它能访问哪些工具?
  • 它用了哪些知识和上下文?
  • 它有没有经过审批?
  • 失败能不能复现?
  • 后来版本修复了什么?

这也是本体论问题。Agent 不是一个由单个 ID 保证同一性的东西。它的身份来自定义、版本、运行、证据和评估共同组成的关系位置。

工程上怎么用这套思路

落到日常设计,不需要每次都拿范畴论术语开会。更好的做法是换一组建模问题。

遇到一个系统,先不要急着列对象。先问:

  • 系统有哪些核心状态?
  • 每个状态能进入哪些下一个状态?
  • 哪些转换是命令,哪些转换是事件?
  • 哪些转换是纯计算,哪些转换有副作用?
  • 哪些转换需要人工审批?
  • 哪些转换必须留下审计和证据?
  • 哪些失败可重试,哪些失败必须阻断?
  • 哪些状态可以回滚,哪些只能追加修正?

然后再决定对象、表结构、接口和 UI。

这个顺序很关键。对象优先时,系统常常长成“字段越来越多的大对象”。转换优先时,系统更容易长成“状态边界清楚的流程网络”。

好的软件不一定都要函数式,也不一定都要数学化。面向对象仍然适合表达边界、封装资源和承载生命周期。但当系统进入工作流、审批、知识治理、Agent 执行、插件权限、审计追踪这些场景时,只靠对象模型会不够。

要把对象和转换分开看:对象负责命名稳定位置,函数负责表达变化,类型负责卡住非法路径,审计负责证明变化发生过,评估负责决定变化是否可靠。

最后回到那根箭头

范畴论看起来是在谈抽象数学,函数式编程看起来是在谈代码风格,本体论看起来是在谈哲学,Agent 生命周期看起来是在谈产品工程。它们之间真正相通的地方,是那根箭头。

一个东西是什么,不能只看它内部装了什么。还要看它从哪里来,能到哪里去,经过什么转换,留下什么证据,能和什么继续组合。

对象当然重要。没有对象,我们无法命名世界。但如果只盯着对象,系统会变成一组静态名词。真正让系统可靠起来的,是那些被认真设计过的转换。

软件工程最后常常败在这里:不是对象不够多,而是箭头不够诚实。