第三章 07:3.9 能力收束与统一入口
课程专题 :《AI Agents 开发实践》
原文链接 :
https://s.juejin.cn/ds/TAqu_hbSmd8/章节 :第三章 3.9 能力收束与统一入口
作者:Java 开发者转型 AI 全栈
3.9.1 按业务域拆分 Service
当能力开始变多时,Service 的拆分方式也该逐渐从"技术演示"切换到"业务域承载"。因此,这里不再继续使用 LlmService 之类的泛化命名,而是直接落到真实业务名:RequirementService。
Java 对照
java
// 反面:Controller 里堆满演示接口
@RestController
@RequestMapping("/api/demo")
public class DemoController {
@PostMapping("/invoke") ...
@PostMapping("/stream") ...
@PostMapping("/batch") ...
@PostMapping("/prompt-preview") ...
// ... 8 个演示接口,业务方不知道该调哪个
}
// 正面:按业务域收束,演示接口保留但业务入口清晰
@RestController
@RequestMapping("/api")
public class RequirementController {
@PostMapping("/requirement/extract")
public RequirementResult extract(@RequestBody ExtractRequest req) {
return requirementService.extract(req.getInput());
}
}
// 演示接口移到 /api/langchain/* 前缀下,供学习复盘用
@RestController
@RequestMapping("/api/langchain")
public class LangchainDemoController { ... }
3.9.2 统一入口 Controller
Service 按业务域收束之后,Controller 也应同步收口成统一入口,而不是继续暴露一堆演示性质的路由。
services/chat/src/app.service.ts
typescript
// services/chat/src/app.service.ts
// 应用级基础服务 ------ 健康探针与共享包最小闭环
import { Injectable } from '@nestjs/common';
import { APP_NAME, type HealthResponse } from '@autix/contracts';
@Injectable()
export class AppService {
// 健康探针:为 Compose/监控提供稳定入口
getHealth(): HealthResponse {
return {
status: 'ok',
version: '0.1.0',
timestamp: new Date().toISOString(),
};
}
// 验证「共享包 + API 返回 + 前端消费」的最小闭环
getHello() {
return { message: 'Hello, Next.js!', app: APP_NAME };
}
}
services/chat/src/app.controller.ts
typescript
// services/chat/src/app.controller.ts
// 3.9.2 统一入口 Controller ------ 演示路由收口,业务入口归一
import { Body, Controller, Get, Post } from '@nestjs/common';
import { AppService } from './app.service';
import { RequirementService } from './llm/requirement.service';
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
private readonly requirementService: RequirementService,
) {}
// 健康探针:为 Compose/监控提供稳定入口
@Get('health')
getHealth() {
return this.appService.getHealth();
}
// 验证「共享包 + API 返回 + 前端消费」的最小闭环
@Get('hello')
getHello() {
return this.appService.getHello();
}
// 3.9 能力收束:把整条链路落到统一业务入口
@Post('/requirement/extract')
async extract(@Body() body: { input: string }) {
return this.requirementService.extract(body.input);
}
}
修改 services/chat/src/app.module.ts
替换为:
typescript
// services/chat/src/app.module.ts
// 根模块 ------ 相当于 Spring Boot 的 @SpringBootApplication
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { LlmModule } from './llm/llm.module';
@Module({
imports: [LlmModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
删除旧文件
删除这两个文件(它们的职责已被 AppController 收口):
services/chat/src/health.controller.tsservices/chat/src/hello.controller.ts
修改 services/chat/src/main.ts
在 app.enableCors() 后面加一行:
typescript
// 全局路由前缀 ------ 所有接口统一挂在 /api 下(课程约定)
app.setGlobalPrefix('api');
修改 services/chat/src/llm/llm.controller.ts
把 @Controller('api/langchain') 改成 @Controller('langchain')(因为全局前缀已设 /api,Controller 里不需要再写 api/):
typescript
@Controller('langchain')
export class LlmController {
3.9.3 前端页面的职责边界
服务端入口收束完成后,前端的职责反而会变得更清楚:它不负责理解模型,不负责解析结构,也不负责规则校验,只负责输入、请求与展示。
clients/chat-web/src/app/page.tsx
tsx
'use client';
import { useState } from 'react';
import type { RequirementResult } from '@autix/contracts';
export default function Home() {
const [input, setInput] = useState('用户注册时必须绑定手机号,密码至少 8 位');
const [result, setResult] = useState<RequirementResult | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit() {
setLoading(true);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/requirement/extract`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
}
);
const data = await res.json();
setResult(data);
} finally {
setLoading(false);
}
}
return (
<main style={{ padding: 24, maxWidth: 720 }}>
<h1>Requirement Extract Demo</h1>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
rows={8}
style={{ width: '100%' }}
/>
<button onClick={handleSubmit} disabled={loading} style={{ marginTop: 12 }}>
{loading ? '提取中...' : '提取'}
</button>
<pre style={{ marginTop: 16 }}>{JSON.stringify(result, null, 2)}</pre>
</main>
);
}
clients/chat-web/.env.local
env
# clients/chat-web/.env.local ------ 前端本地环境变量(已被 .gitignore 排除)
# NEXT_PUBLIC_ 前缀的变量会打包进浏览器 bundle,只能放非敏感配置
NEXT_PUBLIC_API_BASE_URL=http://localhost:4001/api
前端职责边界
前端职责边界可以浓缩成三件事:
- 输入需求文本:提供 textarea 或表单组件,让用户输入原始需求描述,前端只负责采集,不做任何预处理或格式约束。
- 请求统一接口 :通过 fetch 调用
POST /requirement/extract,把输入原样交给服务端,前端不关心背后是哪个模型、哪条链、用了什么工具。 - 展示结构化 JSON:拿到服务端返回的 action / constraints / entities 后直接渲染,前端不需要做二次解析或字段校验。
模型规则、结构化抽取、工具调用、测试与校验都留在服务端处理。前端越薄,后端能力迭代时需要联动改动的地方就越少。
测试文件
services/chat/test/requirement.spec.ts
typescript
// services/chat/test/requirement.spec.ts
// 该用例会发起真实模型调用(耗时且依赖外部服务),默认跳过。
// 需要端到端验证时显式开启:RUN_LLM_E2E=1 bun test
import { describe, it, expect } from 'bun:test';
import { RequirementService } from '../src/llm/requirement.service';
const runIf = process.env.RUN_LLM_E2E ? it : it.skip;
describe('Requirement Extract', () => {
runIf(
'should extract correctly',
async () => {
const service = new RequirementService();
const result = await service.extract(
'用户注册时必须绑定手机号,密码至少 8 位',
);
// 结构化输出约定:字段类型稳定(这是 withStructuredOutput 的核心保证)
expect(typeof result.action).toBe('string');
expect(result.action.length).toBeGreaterThan(0);
expect(Array.isArray(result.constraints)).toBe(true);
expect(Array.isArray(result.entities)).toBe(true);
expect(result.constraints.length).toBeGreaterThan(0);
// 输入中明确出现"手机号",应在抽取结果中有所体现(LLM 措辞可能不同,故只断言语义命中)
expect(JSON.stringify(result)).toContain('手机号');
},
30000,
);
});
说明 :用例运行时,模型返回的字段措辞会随模型/温度波动(例如 action 可能是「用户注册」也可能是「绑定手机号」)。因此断言只校验结构化约定与关键语义命中,而不锁死具体字符串;这正是 withStructuredOutput 想保证的部分。
验收测试
后端验收
cmd
:: 统一业务入口(3.9)
curl.exe -s -X POST http://localhost:4001/api/requirement/extract -H "Content-Type: application/json" -d "{\"input\":\"用户注册时必须绑定手机号,密码至少 8 位\"}"
:: 健康检查
curl.exe -s http://localhost:4001/api/health
期望返回:
json
{
"action": "绑定手机号",
"constraints": ["必须绑定手机号", "至少 8 位"],
"entities": ["用户", "手机号", "密码"]
}
json
{
"status": "ok",
"version": "0.1.0",
"timestamp": "2026-10-16T12:33:57.157Z"
}
前端验收
浏览器打开 http://localhost:3000:
- 页面标题
Requirement Extract Demo,textarea 默认有测试文本 - 点「提取」,按钮变「提取中...」
- 下方展示 JSON:
action/constraints/entities
完整链路验证
用户输入文本 → textarea
↓
点击「提取」→ fetch POST /api/requirement/extract
↓
NestJS AppController → RequirementService.extract()
↓
LangChain: 模板渲染 → 模型调用 → withStructuredOutput(zod schema)
↓
返回 JSON 对象 → 前端展示
前端职责边界:只负责输入、请求、展示。不关心背后是模型/模板/链/工具。
服务端承接复杂度:模型规则、结构化抽取、工具调用都留在服务端。
判断标准
什么时候该收束?
当分散的示例代码已经验证通过,下一步是让它变成可被其他模块、其他团队、其他环境稳定调用的入口时。
下一章
3.10 本章小结:回顾每一层解决了什么问题,提炼可反复使用的判断标准。
敬请期待!
回到索引 :第三章博客索引