第三章 07:3.9 能力收束与统一入口

第三章 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.ts
  • services/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

前端职责边界

前端职责边界可以浓缩成三件事:

  1. 输入需求文本:提供 textarea 或表单组件,让用户输入原始需求描述,前端只负责采集,不做任何预处理或格式约束。
  2. 请求统一接口 :通过 fetch 调用 POST /requirement/extract,把输入原样交给服务端,前端不关心背后是哪个模型、哪条链、用了什么工具。
  3. 展示结构化 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:

  1. 页面标题 Requirement Extract Demo,textarea 默认有测试文本
  2. 点「提取」,按钮变「提取中...」
  3. 下方展示 JSON:action / constraints / entities

完整链路验证

复制代码
用户输入文本 → textarea
    ↓
点击「提取」→ fetch POST /api/requirement/extract
    ↓
NestJS AppController → RequirementService.extract()
    ↓
LangChain: 模板渲染 → 模型调用 → withStructuredOutput(zod schema)
    ↓
返回 JSON 对象 → 前端展示

前端职责边界:只负责输入、请求、展示。不关心背后是模型/模板/链/工具。

服务端承接复杂度:模型规则、结构化抽取、工具调用都留在服务端。


判断标准

什么时候该收束?

当分散的示例代码已经验证通过,下一步是让它变成可被其他模块、其他团队、其他环境稳定调用的入口时。


下一章

3.10 本章小结:回顾每一层解决了什么问题,提炼可反复使用的判断标准。

敬请期待!


回到索引 :第三章博客索引

相关推荐
鬓戈1 小时前
OpenSpec 规格生命周期与 BMAD / Superpowers 对比分析
人工智能
const_qiu1 小时前
第三章 05:3.7 结构化输出与程序化消费
java·人工智能
周末程序猿1 小时前
翻译|一份给"与模型共事的人类"的教程
人工智能
原子延迟1 小时前
iLovePDF免费吗?不限次数的PDF压缩5款(2026)
图像处理·人工智能·计算机视觉·pdf
DongQiShanRen1 小时前
宇宙(上):分身创建——三条件触发与差异化演化
人工智能·深度学习·自然语言处理·数据挖掘
2601_962780911 小时前
2027校招:数学与应用数学转产品运营,如何把数据能力变成项目证据?
大数据·人工智能·产品运营
龍德明宇1 小时前
机器人会冷吗,会饿吗?-龍德明宇
人工智能·机器人·大语言模型llm·负主体性·ai存在论
云和数据.ChenGuang1 小时前
AI 工程师相关就业岗位(细分,偏工程落地,由底层到上层)
服务器·开发语言·人工智能·python
虎鲸不是鱼1 小时前
【虎鲸进化论 15】上下文工程:长周期项目的接力
人工智能·自动化·大模型·llm·自进化agent