系统边界与全栈架构
沿一条请求理解前端、后端、数据库、模型与后台任务的职责。
建议先读:阅读指南与学习路线
本页内容
为什么前端工程师需要重新理解系统边界#
在前端工作中,你通常从接口契约开始,把数据变成界面。成为全栈工程师后,你还要决定这些数据从哪里来、谁有权读取、失败后能否重试,以及哪些状态必须在服务重启后保留。AI 又增加了一层:模型可以提出答案和动作,但它的输出本身不能成为可信授权依据。
本章目标是 L2 的系统理解能力。你不必一开始设计微服务,但应当能画出一次请求经过的组件,标出身份、数据和副作用。前置知识是浏览器请求与 Promise;数据库和模型的细节将在后续章节学习。
一条完整请求经过哪些地方#
浏览器:收集输入、展示状态、允许取消
↓ HTTPS 请求,带登录凭据和请求标识
应用后端:认证 → 参数校验 → 权限检查 → 业务规则
├─ PostgreSQL:用户、会话、文档元信息、任务记录
├─ 文件存储:原文件和可下载产物
├─ 检索组件:在有权访问的资料范围内找证据
├─ 模型适配层:发送上下文、接收文本或工具请求
└─ 后台任务:解析文件、批量入库、长时间运行的操作
↓ 经过验证的结果或流式事件
浏览器:解释当前阶段、展示引用、提示下一步
浏览器可以发任何请求,隐藏按钮不等于权限控制。后端是业务规则的执行者,模型服务是一个外部依赖,数据库是持久化事实的主要来源。缓存改善访问速度,但通常不应成为你唯一保存任务结果的地方。队列保存“还有什么要做”,数据库记录“已经发生了什么”;这两者必须协调,不能假设任何一个调用都永远只发生一次。
三种状态不要混在一起#
界面状态包括输入框、正在打开的面板和滚动位置,通常留在前端。业务状态包括文档归属、任务是否提交、审批是否通过,应由服务端持久保存。执行状态描述一次过程,例如“检索中、模型生成中、工具调用中”,可能短暂存在,也可能需要持久化以支持恢复。
例如用户关掉页面,界面状态消失是正常的;已经创建的业务任务不应消失。一次流式回答中断时,部分文本可以作为不完整草稿保留,但不能把它标记为完整答案。把这三类状态放进一个 loading 变量,会让取消、刷新和恢复都变得含糊。
用一个可运行例子理解分层#
下面是单文件 Node 演示,使用模拟的“可信会话结果”和内存仓库,没有 HTTP 和真实登录。它的用途是展示各层职责,不能直接部署为认证系统。保存为 boundaries.mjs,运行 node boundaries.mjs,无需依赖。
// 仓库只负责查数据;实际项目可以替换成参数化 SQL。
const documents = new Map([
['doc-1', { id: 'doc-1', ownerId: 'alice', text: '报销需附发票。' }],
]);
// 适配器只负责把业务输入转换为模型结果。
// 本例返回固定摘要,目的是离线验证权限与调用顺序。
async function summarizeWithMock(text) {
return { summary: `资料摘要:${text}`, provider: 'offline-mock' };
}
async function summarizeDocument({ actor, documentId }) {
// actor 应来自服务端验证后的会话,不能来自请求 body.userId。
if (!actor) throw new Error('UNAUTHENTICATED');
if (typeof documentId !== 'string' || !documentId) {
throw new Error('INVALID_DOCUMENT_ID');
}
const document = documents.get(documentId);
// 统一“没有”和“无权限”,避免泄露其他用户的文档是否存在。
if (!document || document.ownerId !== actor.id) {
throw new Error('DOCUMENT_NOT_FOUND');
}
// 权限确认后才把内容发送给依赖,避免先泄露再拒绝。
const result = await summarizeWithMock(document.text);
return { documentId: document.id, summary: result.summary };
}
console.log(await summarizeDocument({
actor: { id: 'alice' }, documentId: 'doc-1',
}));
try {
await summarizeDocument({ actor: { id: 'bob' }, documentId: 'doc-1' });
} catch (error) {
console.log(error.message); // DOCUMENT_NOT_FOUND
}
第一次调用返回摘要对象,第二次输出 DOCUMENT_NOT_FOUND。业务服务组合权限和外部能力;仓库不负责猜测用户身份;模型适配器不负责决定是否有权访问文档。把接口换成真实 HTTP 时,再由传输层把错误映射成状态码,例如未登录映射为 401,参数不合法映射为 400。
summarizeDocument 接受一个包含 actor 与 documentId 的对象,成功返回 Promise 包装的摘要对象,失败抛出错误。真实应用应使用有明确错误码的错误类型,避免用英文错误消息作为长期接口协议。本例用字符串是为了让调用次序清楚,而不是推荐用散落的字符串建立大型错误系统。
什么该交给普通代码,什么适合模型#
金额计算、权限判定、唯一约束、任务状态流转,应当使用确定的规则。自然语言提取、资料摘要、问题改写等可以交给模型,再由程序检查输出是否满足契约。模型适合处理语义不确定性,不应获得“决定自己权限”的权力。
产品需求也不等于必须使用 AI。固定字段转换可以直接写函数,精确查单号先查数据库。判断依据是任务的语义复杂度、错误成本与验收方法。如果一个简单查询已经足够,增加模型会额外增加延迟、费用和失败方式。
单体服务与模块边界#
第一版可采用一个后端服务、一个数据库、一个文件存储。模块边界通过函数、目录、接口契约和权限规则建立,不需要先通过网络拆开。即使以后把文档解析移到独立 Worker,业务代码仍可依赖同样的“创建解析任务”契约。
只有当部署节奏、资源需求或故障隔离有明确理由时才拆服务。过早拆分会引入网络超时、分布式一致性、跨服务排障与更多运维工作。对个人产品,清晰的单体通常更便于理解和维护;它也可以拥有严格的接口与充分测试。
常见故障如何沿边界定位#
界面没有内容时,先看请求是否发出,再看后端是否接收,之后检查权限、数据库查询、模型调用和响应解析。每一层记录同一个请求标识,配上阶段、耗时和错误类别。不要把完整提示词、凭据或原始私人文档作为默认日志。
另一个常见问题是“取消了页面,但任务还在执行”。取消浏览器等待只说明客户端不再接收;是否向上游传递取消、是否允许中止后台业务,由服务端契约决定。对已产生的数据库写入,关闭网络连接不会自动回滚。
从一句用户需求展开一条完整链路#
用户说:“根据我上传的报销制度,告诉我哪些材料缺失。”前端先要知道文件是否已经上传、处理是否完成、资料属于谁、问题属于哪个会话。后端先确认身份和资料权限,再读取可用版本,检索证据,组织模型输入,校验答案并返回引用。模型负责理解描述,规则负责决定能否访问和执行。这些步骤不能因为你同时使用一个框架就自动成立。
把需求拆成三个阶段比较容易理解。第一阶段准备资料:文件保存、登记元信息、后台解析、发布索引。第二阶段回答问题:验证主体、筛选证据、生成、校验、保存结果。第三阶段执行业务:生成草稿、用户确认、重新鉴权、幂等执行、记录结果。每一阶段都应在失败后留下可解释状态,而不只是接口成功或异常。
请求、任务与事实的生命周期#
HTTP 请求通常在秒级范围结束,后台解析任务可能跨越服务重启,文档与操作结果需要长期保存。不要把三者绑定在同一个 Promise 上。接口返回202和任务ID,意味着服务接受了任务,不意味着解析或索引已完成。页面可用该ID查询状态,后台通过持久化记录恢复执行。
队列消息也不应携带“永远有效的权限结论”。成员可能被移出组织,文档可能已删除,版本可能被更新。Worker开始处理时读取当前业务状态;检索发布前再次确认文档和版本仍有效。否则一个几分钟前合法的任务可能在权限撤销后继续发布资料。
| 状态保存在哪里 | 适合保存什么 | 不适合承担什么 |
|---|---|---|
| Vue组件 | 输入草稿、面板、滚动、当前请求资源 | 已执行任务的唯一结果 |
| 客户端Store | 多页面共享状态与缓存 | 服务端授权事实 |
| 数据库 | 归属、版本、执行记录、幂等结果 | 大文件的任意无限正文 |
| 文件存储 | 原文件与生成文件 | 独立决定用户访问权限 |
| 缓存 | 可重建的热点数据 | 唯一不可丢业务事实 |
| 队列 | 待执行工作与重试调度 | 不加协调地取代业务状态 |
| 模型上下文 | 当前推理需要的信息 | 可靠、永久、受约束的数据库 |
这些是职责划分,不是产品列表。你可以用同一个数据库保存队列式任务,也可以先用磁盘目录存学习文件;关键在于清楚地定义恢复、一致性和访问方式。不要把“使用Redis”当作“已经有可靠任务系统”的同义词。
完整实验:通过调用轨迹验证边界次序#
下面的程序把仓库、模型和结果保存都作为可注入依赖,使用数组记录调用轨迹。保存为 orchestrate-answer.mjs 后用 Node 运行。它展示授权发生在模型调用前、引用在保存前校验、依赖失败不会伪造成功。这里使用内存数据与固定模型结果,专门验证控制流,不包含真正登录或事务。
import assert from 'node:assert/strict';
function createAnswerService({ repository, model }) {
return async function answer({ actor, documentId, question }) {
if (!actor?.id) throw new Error('UNAUTHENTICATED');
if (typeof question !== 'string' || !question.trim()) throw new Error('INVALID_QUESTION');
// 查询本身就限定主体,禁止先读取所有资料再交给模型判断权限。
const document = await repository.findVisible(actor.id, documentId);
if (!document) throw new Error('DOCUMENT_NOT_FOUND');
if (document.status !== 'ready') throw new Error('DOCUMENT_NOT_READY');
const allowed = new Set(document.chunks.map(chunk => chunk.id));
const result = await model.generate({ question: question.trim(), chunks: document.chunks });
if (!result || typeof result.text !== 'string' || !Array.isArray(result.citations)
|| result.citations.some(id => !allowed.has(id))) throw new Error('INVALID_MODEL_OUTPUT');
const record = { actorId: actor.id, documentId, versionId: document.versionId,
text: result.text, citations: result.citations };
// 保存失败会抛出,不能只打印一条日志然后告诉用户已保存。
await repository.saveAnswer(record);
return record;
};
}
function setup({ wrongCitation = false, saveFails = false } = {}) {
const trace = [];
const service = createAnswerService({
repository: {
async findVisible(actorId, documentId) {
trace.push('read-authorized');
if (actorId !== 'alice' || documentId !== 'doc-1') return null;
return { status: 'ready', versionId: 'v1', chunks: [{ id: 'c1', text: '报销需要发票。' }] };
},
async saveAnswer(record) {
trace.push('save');
if (saveFails) throw new Error('DATABASE_UNAVAILABLE');
assert.equal(record.versionId, 'v1');
},
},
model: {
async generate({ chunks }) {
trace.push('model'); assert.equal(chunks.length, 1);
return { text: '需要提供发票。', citations: [wrongCitation ? 'hidden' : 'c1'] };
},
},
});
return { service, trace };
}
const input = { actor: { id: 'alice' }, documentId: 'doc-1', question: '需要什么材料?' };
const normal = setup(); await normal.service(input);
assert.deepEqual(normal.trace, ['read-authorized', 'model', 'save']);
const denied = setup();
await assert.rejects(() => denied.service({ ...input, actor: { id: 'bob' } }), /NOT_FOUND/);
assert.deepEqual(denied.trace, ['read-authorized']);
const invalid = setup({ wrongCitation: true });
await assert.rejects(() => invalid.service(input), /INVALID_MODEL_OUTPUT/);
assert.deepEqual(invalid.trace, ['read-authorized', 'model']);
const failed = setup({ saveFails: true });
await assert.rejects(() => failed.service(input), /DATABASE_UNAVAILABLE/);
console.log('通过:授权顺序、引用范围、保存失败与成功轨迹');
createAnswerService 接收能力接口,返回真正处理业务的函数。这种依赖注入不需要先上复杂框架,普通函数就足够。HTTP handler把会话和请求转换成输入,仓库实现可以换成SQL,模型实现可以换成供应商适配器;业务流程不需要知道它们的连接细节。更换依赖后,仍要做真实集成测试确认契约一致。
注意实验没有解决生成期间权限发生变化的竞态。产品需要明确“以请求开始时权限为准”还是“每次取资料与操作时检查当前权限”,以及撤销之后历史回答如何处理。对敏感资料可以在返回或保存之前再次核对版本与访问策略,但也不能承诺已经传给外部模型的内容能被取消连接自动撤回。边界设计必须包含时间维度。
哪些副作用可以放在数据库事务里#
一个事务可以把同一数据库中的配额扣减和任务登记一起提交,却不能自动撤回已经发送的邮件或已经计费的模型调用。把外部网络请求放在长事务中等待,会持有连接或锁,并把上游延迟传递给数据库。更合理的设计是先记录执行意图,再由受控流程调用外部能力并记录结果。
如果“业务记录提交”和“入队”分别进行,进程可能在两者之间崩溃。业务已创建但永远没有任务,或者任务执行时查不到业务记录,都可能发生。outbox思路是在同一数据库事务里保存业务事实和待发送事件,再由后台分发。分发可能重复,所以消费端仍需幂等。知道这种故障窗口,比一开始接入很多中间件更有价值。
你不必在第一个学习程序里实现完整outbox,但要会画出每一步的提交点,并问“这里崩溃后怎样恢复”。内存队列在重启后丢失任务是否可以接受?定时扫描数据库能否找回未完成任务?哪种失败需要用户重试,哪种应该自动恢复?答案取决于产品要求,不能由技术名词替你决定。
模型适配层应该隔离什么#
业务层需要稳定的输入和输出,例如“给定问题和证据,返回回答与引用”,而供应商可能提供不同的消息字段、流事件、工具描述和错误格式。适配层负责转换这些差异,保留必要的用量、请求ID和错误类别。不要把供应商原始响应直接传遍所有Vue组件,否则切换API版本会扩散成全项目修改。
适配层也不能过度抽象到看不见重要差异。若一个模型不支持结构约束、工具调用或某种模态,应明确能力限制,不能偷偷用普通文本模拟并宣称行为相同。统一接口需要定义最低共同语义,并让调用者知道哪些能力可用。先接一个供应商做清楚,再判断第二个供应商的真实差异。
提示词放在哪里同样是模块边界问题。固定系统规则、业务上下文、检索证据和用户问题应该有清晰来源;不要在前端字符串里拼业务权限,在后端再拼一遍规则,最后没人知道哪份提示词生效。提示词可以版本化并参与评测,但授权、金额和状态规则仍由程序执行。
缓存不能绕开权限与版本#
问答缓存至少考虑问题、资料版本、可访问范围、模型与提示配置。只按问题文本缓存,会让两个用户得到彼此权限范围内的结果。即使把userId加进key,成员权限变更或文档删除后,旧缓存仍可能不再合法。需要权限版本、资料版本、失效机制或读取时再核对。
向量索引也不是业务事实的唯一来源。某文档删除后,索引清理可能异步完成,因此检索结果在进入模型前仍要确认业务可见性。设计“删除成功”时,先撤销访问,再进行物理清理,用户才不会在清理等待期间继续检索到被撤销内容。具体版本策略在需求、资料入库与检索章节展开。
单体的目录边界与未来拆分#
开始时可以按领域划分documents、conversations、operations,再在每个领域内部组织HTTP入口、业务服务和数据访问。共享的模型适配、日志和配置放在明确的基础模块里。这个布局不是唯一正确答案,重点是一个变更能否沿接口清楚定位,而不是每层必须有多少文件。
当解析任务需要大量CPU或独立扩容时,先把它移动到Worker进程;当一个模块确实需要独立部署和故障隔离时,再考虑服务边界。跨进程之后要新增超时、重试、认证和消息兼容,不能只把函数调用换成HTTP然后认为拆分完成。对个人项目,能维护的复杂度本身就是选型条件。
架构评审时问这些具体问题#
资料从哪里进入系统,谁拥有它,哪些地方保存副本,何时可以被检索,删除后哪些副本需要失效?用户身份从哪里建立,模型和工具分别能看到什么,哪一次检查阻止越权?操作在什么时刻算成功,网络中断后如何查回,重复提交由谁去重?一段结果对应哪份资料和配置,发生错误时用什么标识串起记录?
能回答这些问题,就达到本章L2所要求的系统理解。你不需要先背完分布式系统理论,但应能在纸上画出一次请求,标出持久化事实、外部数据发送、权限检查和故障恢复点。后续每个技术章节,都应能放回这张图中的某个位置。
练习与参考答案#
为“上传文档并建立知识索引”写出前端、接口、文件存储、数据库、解析任务的职责,并回答:什么时候向用户显示“上传成功”,什么时候显示“可检索”?
参考答案
上传成功意味着原始文件已可靠保存,并已记录归属;可检索意味着解析、分块、向量化和索引发布完成。接口返回文档 ID 与处理中状态,前端轮询或订阅状态变化。解析失败保留错误类别与重试入口,不把“文件保存成功”直接展示成“知识库已就绪”。旧版本索引可继续提供服务,新版本准备完成后再切换。
验收与自测#
- 能给一次问答请求标注权限检查和外部数据发送的位置。
- 能区分前端状态、业务状态、执行状态。
- 能解释为什么一个单体服务也需要清晰的模块边界。
问:模型说某用户是管理员,能据此授权吗? 答:不能,身份和角色来自受信任的服务端记录。
问:把模块拆成服务就有了清晰架构吗? 答:不一定。职责不清时,网络边界只会放大问题。
问:成功收到 HTTP 200 就证明业务任务完成吗? 答:取决于契约,异步任务的响应可能只代表已接收。