项目刚开始时,团队常常面临两种担心:设计得太多,产品还没跑通,就先背上了一套框架;设计得太少,几个功能上线后,改一次底层依赖又会牵动整条业务链。
“先做起来,以后再重构”听起来合理。但它留下了一个问题:什么可以以后再改,什么一旦进入数据和业务流程,就很难只靠重构解决?
我们在 TraceWise 接入共享 Agent 基础设施时,反复碰到了这个区别。产品要让用户围绕项目资料提问、查证并发起更新;基础设施负责一部分数据权威、检索和受控执行。两边可以各自迭代,却不能让一次底层调整悄悄改变“查不到”“已批准”“已完成”的含义。
这段经历让我更关心一个发生在项目早期的问题:在第一条业务链路接通之前,应该先确定哪些边界,才能保留以后调整实现的自由?
一、先决定什么不该扩散,再决定代码怎么分层
假设正在开发一个知识问答功能。最短的实现路径是:收到问题,调用检索 SDK,把返回内容交给模型,然后展示答案。
接通这条路径并不难。难的是后续几个功能也开始依赖检索:调查页面要展开关系,资料导入要检查来源,对话追问要恢复历史。它们逐渐知道同一个外部服务如何表达项目身份、如何分页、返回什么异常。服务一变,业务代码便需要一起理解这次变化。
这时再建一个 adapters 目录,可以集中调用位置,却未必能收回已经扩散的假设。
图 1|设计对照。箭头表示调用依赖,红色虚线强调与外部协议接触的位置,不是实测修改范围。接入层可以集中转换协议;业务含义改变时,产品仍需调整。
Parnas 在《On the Criteria to Be Used in Decomposing Systems into Modules》中比较了不同的模块划分方式。他提出,应围绕困难或可能变化的设计决定组织模块,并把这些决定隐藏起来,而不只是把处理流程的每一步变成一个模块。这里参考的是 1971 年技术报告版本。Parnas 原文,结论部分
据此看产品集成,早期最值得问的不是“要不要多一层”,而是:“外部系统的哪些细节,不应该成为所有业务功能的共同知识?”
身份映射、请求协议、分页方式、错误格式,通常可以集中在接入模块。至于一条资料是否足以支持结论、调查要不要继续、什么修改需要用户确认,则仍属于产品的业务判断。
这种划分不要求预先知道所有需求。即使目前只有一个实现,也可以先有一个小模块,明确哪些调用只能经过它。接口先服务于眼前的业务,不必为了想象中的第二家供应商,把所有输入输出变成通用字典。
二、比 SDK 更早要讲清楚的,是数据归属
接入共享服务,很容易被理解为“把本地函数换成 HTTP 请求”。但如果服务接管了正式数据,变化就不只是通信方式。
以一次知识更新为例:产品生成了候选节点,用户看过差异,服务接受了请求,执行器稍后才将变化写入。这里至少有候选结果、授权记录和正式数据三种不同性质的东西。
如果没有先说明哪一份是权威状态,产品可能把自己的预览当成正式图谱,也可能在请求超时后重新生成一笔更新。后者不是换个字段名就能修好的问题:它已经涉及同一操作的身份、重复执行和历史记录。
因此,我会把下面这张表放进第一次接入讨论,而不是留到“系统做大了再治理”。它是设计时要回答的问题,不是某个框架的强制模板。
| 先要确认的边界 | 对产品实现的直接影响 | 边界不清时容易发生的事 |
|---|---|---|
| 谁拥有正式数据,谁保存副本或投影 | 明确读写入口,以及本地状态能否作为事实依据 | 预览、缓存与正式记录互相冒充 |
| 产品对象如何关联服务对象 | 保存稳定映射及作用域,不靠名字猜身份 | 重连时新建一份身份,旧历史无法继续 |
| 接受、批准、生效分别由谁确认 | 分开保存状态,并在界面说明等待什么 | 请求受理就显示任务完成 |
| 超时后如何确认同一操作的结果 | 保留操作身份,按合同查询或幂等重试 | 没收到响应就当没发生,再写一次 |
这不意味着共享服务拥有一切。TraceWise 仍然需要自己的对话、任务和界面状态;正式 Graph/Evidence 的权威归属,也不能替产品决定一段话是否回答了用户的问题。
更稳妥的分工是:产品保存业务意图、关联标识和必要投影,通过约定的入口获取或变更权威数据。副本可以存在,但用途必须明确;不能因为服务暂时不可用,就把本地副本升级为未经约定的新权威。
提前确定这些关系,并不等于提前冻结存储技术。恰恰相反,明确了产品依赖的是哪些身份与行为,底层才更有机会改变存储实现,而不要求产品同时重写历史。
三、接入层保护的是含义,不只是调用位置
考虑两个外部响应:一个是检索正常完成、没有命中;另一个是检索能力尚未配置。
如果二者都被转换成空列表,产品可能给出同一句“资料中没有相关内容”。接口类型看起来统一了,用户得到的判断却错了。前者允许产品在限定检索范围内说明未找到依据;后者只能说明现在无法完成这次检索。
接入层需要保留这种差别。它可以把供应商的错误码转换成产品能理解的状态,但不能擅自把不可用变成成功,更不能因为缺少能力,就静默换一种业务含义不同的检索方式。
Cockburn 的 Ports and Adapters 文章强调应用内部与外部技术交互的分离,并把独立测试作为重要目标。这给我们的启发是:业务测试应当能够表达“没有证据”和“暂时不能取证”的不同结果,而不必依靠真实外部服务恰好发生故障。Cockburn:Hexagonal Architecture
不过,独立测试不证明真实服务可用。替身可以验证产品怎样处理一个超时,却不能证明安装后的 SDK 能解析服务当前的响应。前者验证业务决策,后者验证接线是否兼容,两种检查都需要。
在 TraceWise 的这次结构调整中,我们把已有的 Foundation 客户端、Context、Evidence、Graph、项目开通及提案相关接入实现,收拢到产品自己的 outbound adapter 目录,旧入口保留兼容导入。目的不是重新设计调查算法,也不是把产品流程搬进基础设施。
但有一个限度必须看清:当前部分合同类型仍直接重导出 SDK 的类型。这减少了导入位置的分散,并没有自动建立一套独立于 SDK 的产品语义。如果这些类型的含义变化仍然传入业务层,后续就需要在真正受影响的位置补充产品拥有的输入、输出与映射。
这也是我对“稳定接入层”的定义:让变化有明确的处理位置,并让无法兼容的变化被发现;不是承诺外部怎么变,产品都能无条件继续运行。
四、两个产品接同一服务,不必共用一层业务适配
当 TraceWise 和 TeachFlow 都需要共享基础设施时,很容易进一步提出:把两个产品的接入层提取成一个公共包,减少重复代码。
但“都调用检索”不等于“检索对它们意味着同一件事”。
作为需求对比,知识调查可能需要同时呈现支持材料与反证;教学辅导可能需要根据学习阶段,决定向学生展示提示还是完整解释。这是两种产品决策,不应仅仅因为调用了同一个服务,就被塞进同一套适配逻辑。这里讨论的是设计选择,不是声称两个产品已经完成了相同改造。
可以共享的是服务协议的客户端:如何发送请求、解析公开响应。产品怎样组织输入、解释失败、选择下一步,则应由各自的接入层与业务层负责。
图 2|设计示意,不是当前部署图。上方分别保留调查与教学的判断,下方共享服务协议;不要求共建一套业务适配包。
| 接入方式 | 什么时候合理 | 要承担的代价 |
|---|---|---|
| 在少量代码中直接使用 SDK | 短期验证、调用很少、外部类型正好符合需求 | 后续增加调用点时,要重新检查假设是否扩散 |
| 每个产品保留自己的接入模块 | 外部交互已服务多个业务流程,或双方状态含义不同 | 维护映射、边界测试和安装态验证 |
| 提取跨产品公共适配包 | 已有可观察的共同需求,且适合一起维护和发布 | 公共包升级可能要求多个产品协调;差异不能被强行抹平 |
微软的 Anti-Corruption Layer 文档把这种转换放在不同语义的系统之间,同时提醒其维护和延迟成本;如果双方没有显著语义差异,这个模式未必适合。它也不应成为新的业务编排中心。Microsoft:Anti-Corruption Layer
所以,共享应当是比较过实际行为之后的结论。重复几行调用代码,未必比让两个业务节奏不同的产品共同依赖一套解释规则更昂贵。
五、早做边界,不等于早做完整框架
一个合理的反问是:既然很多未来变化还不知道,为什么不等遇到问题再处理?
对于只运行一次的数据整理脚本,或者两天后就丢弃的原型,这完全可能是更好的选择。只有一个外部调用,失败时直接终止,不保存长期身份,也没有第二条业务流程需要复用,此时增加接口体系很可能只是增加阅读成本。
Fowler 在解释 YAGNI 时,反对为推测中的未来需求提前加入能力与抽象,但明确区分了这种工作和保持代码易于修改的投入。重构与自动化测试,正是支持渐进设计的条件。Fowler:Yagni
我据此采用的判断不是“早设计”或“晚设计”二选一,而是比较两种成本:现在做这件事增加多少复杂度;现在不做,未来要撤回哪些已经形成的承诺。
把一个 SDK 调用集中到小模块,成本可能很低。预先支持五种数据库、三套权限模式、任意产品插件,则需要持续维护尚未用到的选择。另一方面,一旦用户数据按某种身份关系写入,或者界面已经把“接受”解释成“完成”,撤回这些承诺就可能涉及迁移和用户操作,而不只是代码重构。
因此,可以晚一点决定通用接口有多少方法、是否独立发布公共包、是否拆成服务;但应尽早说明谁拥有数据、谁能执行操作、失败意味着什么,以及业务代码从哪里访问外部能力。
图 3|作者的定性判断,没有实测分数。“明确数据身份”不等于提前实现复杂的数据治理;公共包的撤回代价取决于实际采用深度,各项位置应随项目重新判断。
这些边界也不是永久不变。它们应当允许有意识地修订,而不是在一次看似普通的依赖升级中被悄悄改掉。
六、第一条链路,怎样才算把边界做实了
如果把这次经验带回项目起点,我会选一个真实用例,把边界落到能执行的检查上,而不是先画一张覆盖全部未来功能的架构图。
例如,对“查资料并回答问题”,除了命中后的正常回答,还要检查无命中、能力未就绪和权限不足能否被区分。对“确认一项更新”,则要检查请求已接受但尚未生效时,页面是否明确显示等待执行;丢失响应后,能否继续追踪同一操作。
代码结构也要有检查:外部 SDK 是否只从指定入口进入;业务层是否越过接口读外部存储;一个类型只是被换了导入路径,还是确实做了必要的含义转换。不是每个字段都值得复制一套产品模型,但发生差异的地方不能假装没有差异。
TraceWise 的本地改造还暴露了一个很具体的细节:新增 adapter 目录后,需要同步调整 Python 包的包含规则。源码目录里能导入,不代表安装后的产品包含这些文件。当前候选已把 app.adapters* 纳入打包范围;这类检查提醒我们,接入边界要在交付物中成立,而不只是工作区里看起来清楚。
这里的项目观察来自本地代码调整与安装检查,不能推出未来升级成本已经下降,更不意味着真实 Agent 对话的全部业务验收已完成。要检验长期价值,还需要观察下一次真实合同变化:修改是否集中、无关业务是否保持不动、失败是否在交付前暴露。
早期架构的价值,不是一次设计完未来。它是先把那些会进入数据、权限和用户认知的承诺说清楚,再让实现围绕这些承诺逐步生长。
可以推迟的是尚未需要的能力;不应无意推迟的,是谁负责什么、哪些状态不能混淆,以及一次变化应该在哪里被处理。