2026 前端 AI Agent 工程化实战营系列第 4 篇

本文整理自《2026 前端 AI Agent 工程化实战营》课程资料,作为系列文章第 4 篇。

image.png

前两章里,我们已经把工程底座搭了起来。接下来这一章要解决的,是一个更贴近真实开发的问题:当“调用一次模型”已经不够用时,怎样把它一步步整理成可维护、可扩展、可测试的服务端能力。

LangChain 的价值,不在于“再包一层模型调用”,而在于它把模型、提示模板、输出解析、工具调用、检索与链路编排收进了一套相对统一的编程模型。这样一来,原本分散、易碎、难复用的能力,就有了更稳定的落点。

因此,本章依然沿着同一条递进路线展开,只不过这一次,我们会把每一步都放进更明确的工程语境里:

与前两章一样,整章依然围绕同一个业务需求递进展开。但这次我们不再停留在模糊、不可验证的示例上,而是直接落到一个真正能落地的任务:从需求文本中抽取结构化字段 action / constraints / entities


3.1 引入 LangChain 的必要性

如果只做一次最简单的模型调用,直接使用 OpenAI SDK 完全没有问题。但真实项目不会停留在“只调一次”这个阶段,复杂度很快就会沿着不同维度扩散出来:

换句话说,引入 LangChain,并不是为了“再包一层调用”,而是为了在工程上提前把这些问题收口。

generated-image-1774765273638.png

不过,比技术选型更关键的,其实是先把问题定义正确。如果任务只是“提取核心目标 / 限制 / 关键词”,那它天然就不稳定:

所以,本章会把同一条递进路线,落到一个可验证、可测试、可复用的任务上:

{
  action: string;
  constraints: string[];
  entities: string[];
}

例如输入:

用户注册时必须绑定手机号,密码至少8位

预期输出:

{
  "action": "用户注册",
  "constraints": ["必须绑定手机号", "密码至少8位"],
  "entities": ["用户", "手机号", "密码"]
}

为了更清楚地对比“每加一层能力”到底带来了什么变化,后文会始终围绕这段需求抽取任务,按下面五个层次递进实现:

  1. 模型层:先学会创建模型,理解 invoke()stream()batch() 分别适合什么场景
  2. 提示层:把原本写死在代码里的长字符串整理成模板,学会用变量填充
  3. 解析层:让模型返回更适合继续进入代码,而不是只得到一段自由文本
  4. 工具层:让模型可以根据上下文决定是否调用校验工具
  5. 收束层:把能力落回业务 Service / Controller,形成稳定、可验证的接口

3.2 服务端承载模型能力的分工原则

明确了任务之后,下一步要解决的就是:这些能力应该放在哪一层。

这里继续沿用前两章的分工方式:

之所以把复杂度放到服务端来承接,不只是出于“习惯”,而是因为服务端天然更适合收口这些持续增长的能力:

补充:Next 与 Nest 的区别,以及为什么这里不直接用 Next 充当服务层底座

Next 可以承担 BFF 与部分接口,但不适合作为本书后续的“主服务层底座”。核心差异在于两者的关注点不同:

结论更准确地说:

也正因为如此,后面所有示例都会尽量围绕服务端组织,而不是把 LangChain 当成浏览器里的一个临时调用工具。


3.3 LangChain 接入前的项目准备

在真正开始写调用链之前,先把项目里的配置和依赖准备好。这样后面每往前多走一步,都有稳定的落点,而不是一边写示例,一边返工基础设施。

3.3.1 安装依赖

服务端需要以下依赖:

在服务端安装:

bun add langchain @langchain/openai @langchain/core js-yaml --cwd services/api

共享层的 zod 沿用前面已安装的版本。

3.3.2 配置分层:环境变量与 YAML

LangChain 的配置,建议从一开始就拆成两层:

这样做的目的很简单:把“安全相关的变化”和“运行时策略的变化”分开处理,后面维护会轻松很多。

令牌与密钥:环境变量注入

敏感凭证必须和代码解耦。

开发阶段放在 .env

# services/api/.env
OPENAI_API_KEY=sk-xxxx
OPENAI_BASE_URL=https://api.amux.ai/v1
EMBEDDING_API_KEY=sk-xxxx
VECTOR_DB_URL=http://localhost:6333
VECTOR_DB_API_KEY=

生产阶段通过 Docker Compose 直接注入,不落盘任何密钥文件:

services:
  api:
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - OPENAI_BASE_URL=${OPENAI_BASE_URL}
      - EMBEDDING_API_KEY=${EMBEDDING_API_KEY}
      - VECTOR_DB_URL=${VECTOR_DB_URL}
      - VECTOR_DB_API_KEY=${VECTOR_DB_API_KEY}

运行参数:YAML 化管理

和敏感凭证不同,运行参数更适合落在可读、可调的配置文件里:

services/api/config/langchain.yaml

示例:

llm:
  provider: openai
  model: gpt-5.4
  temperature: 0
  maxTokens: 800

retrieval:
  enabled: true
  topK: 3

tools:
  enableConstraintCheck: true
  enableEntityLookup: true

features:
  enableStructuredOutput: true
  enableStreaming: true

3.3.4 配置加载器的实现

配置分层之后,下一步就是把它们收束到一个统一的加载入口里。

services/api/src/config/load-langchain-config.ts

import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';

export type LangChainAppConfig = {
  llm: {
    provider: string;
    model: string;
    temperature: number;
    maxTokens: number;
  };
  retrieval: {
    enabled: boolean;
    topK: number;
  };
  tools: {
    enableConstraintCheck: boolean;
    enableEntityLookup: boolean;
  };
  features: {
    enableStructuredOutput: boolean;
    enableStreaming: boolean;
  };
};

export function loadLangChainConfig(): LangChainAppConfig {
  const filePath = path.join(process.cwd(), 'config', 'langchain.yaml');
  const raw = fs.readFileSync(filePath, 'utf8');
  return yaml.load(raw) as LangChainAppConfig;
}

export function getApiKeys() {
  return {
    openaiApiKey: process.env.OPENAI_API_KEY ?? '',
    openaiBaseUrl: process.env.OPENAI_BASE_URL,
    embeddingApiKey:
      process.env.EMBEDDING_API_KEY ?? process.env.OPENAI_API_KEY ?? '',
    vectorDbUrl: process.env.VECTOR_DB_URL,
    vectorDbApiKey: process.env.VECTOR_DB_API_KEY,
  };
}

到这里,配置层就算准备完成了。接下来就可以正式进入模型调用本身。


3.4 模型调用基础

3.4.1 统一模型工厂的建立

有了配置加载器之后,模型初始化也应该统一收口到工厂函数里,而不是散落在各个 Service 中。

services/api/src/llm/model.factory.ts

import { ChatOpenAI } from '@langchain/openai';
import {
  loadLangChainConfig,
  getApiKeys,
} from '../config/load-langchain-config';

export function createChatModel() {
  const config = loadLangChainConfig();
  const keys = getApiKeys();

  return new ChatOpenAI({
    model: config.llm.model,
    temperature: config.llm.temperature,
    maxTokens: config.llm.maxTokens,
    openAIApiKey: keys.openaiApiKey,
    configuration: keys.openaiBaseUrl
      ? { baseURL: keys.openaiBaseUrl }
      : undefined,
  });
}

接下来再搭一组 NestJS 骨架。后面每种能力,都会以 Service + Controller 的形式落地。

services/api/src/llm/llm.module.ts

import { Module } from '@nestjs/common';
import { LlmService } from './llm.service';
import { LlmController } from './llm.controller';

@Module({
  providers: [LlmService],
  controllers: [LlmController],
  exports: [LlmService],
})
export class LlmModule {}

services/api/src/llm/llm.service.ts

import { Injectable } from '@nestjs/common';
import { createChatModel } from './model.factory';

@Injectable()
export class LlmService {
  private model = createChatModel();
}

services/api/src/llm/llm.controller.ts

import { Body, Controller, Post, Res } from '@nestjs/common';
import type { Response } from 'express';
import { LlmService } from './llm.service';

@Controller('api/langchain')
export class LlmController {
  constructor(private readonly llmService: LlmService) {}
}

3.4.2 invoke() 的基本用法

模型工厂搭好之后,最自然的起点就是 invoke()。这是最基础、也最常见的单次调用方式,适合先把主链路跑通。

它尤其适合下面几类场景:

Service 新增方法:

import { HumanMessage, SystemMessage, type BaseMessage } from '@langchain/core/messages';

async invokeDemo(input: string): Promise<string> {
  const systemMessage = new SystemMessage('你是一名需求结构化抽取助手');
  const humanMessage = new HumanMessage(
    `请从下面文本中抽取 action、constraints、entities:\n${input}`
  );
  const messages: BaseMessage[] = [systemMessage, humanMessage];
  const response = await this.model.invoke(messages);
  return response.content.toString();
}

Controller 新增路由:

@Post('invoke')
async invoke(@Body() body: { input: string }) {
  const result = await this.llmService.invokeDemo(body.input);
  return { result };
}

curl 验证:

curl -s -X POST http://localhost:3001/api/langchain/invoke \
  -H "Content-Type: application/json" \
  -d '{"input": "用户注册时必须绑定手机号,密码至少8位"}'

原始图片缺失占位图

3.4.3 stream() 的流式调用方式

当单次返回能跑通之后,接下来就可以看 stream()。它适合那些“结果不必等到全部生成完才展示”的场景,前端也可以据此做逐步渲染。

Service 新增方法:

async streamDemo(input: string) {
  return this.model.stream([
    new SystemMessage('你是一名需求结构化抽取助手'),
    new HumanMessage(`请逐步分析并输出结构化抽取结果:\n${input}`),
  ]);
}

Controller 新增路由(SSE 方式):

@Post('stream')
async stream(@Body() body: { input: string }, @Res() res: Response) {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const stream = await this.llmService.streamDemo(body.input);

  for await (const chunk of stream) {
    res.write(chunk.content);
  }

  res.end();
}

curl 验证:

curl -s -X POST http://localhost:3001/api/langchain/stream \
  -H "Content-Type: application/json" \
  -d '{"input": "用户注册时必须绑定手机号,密码至少8位"}'

image 1.png

3.4.4 batch() 的批量处理方式

如果说 invoke() 解决的是“跑通一条”,stream() 解决的是“边生成边返回”,那么 batch() 解决的就是“同一套处理逻辑一次跑多条”。

Service 新增方法:

async batchDemo(inputs: string[]) {
  const messageGroups = inputs.map((input) => [
    new SystemMessage('你是一名需求结构化抽取助手'),
    new HumanMessage(`请抽取 action、constraints、entities:\n${input}`),
  ]);

  const responses = await this.model.batch(messageGroups);
  return responses.map((item) => item.content.toString());
}

Controller 新增路由:

@Post('batch')
async batch(@Body() body: { inputs: string[] }) {
  const results = await this.llmService.batchDemo(body.inputs);
  return { results };
}

3.4.5 三种调用方式的适用场景

到这里,三种最基础的调用方式就都具备了。后面所有更复杂的层次,本质上也都是在这三种执行方式上继续往前叠能力。

generated-image-1774765269063.png

顺着这个基础,下一步就可以开始解决另一个真正会迅速膨胀的问题:提示词本身。


3.5 提示模板化改造

3.5.1 字符串拼接方式的局限

当接口和任务开始变多之后,直接拼接字符串的方式会越来越难维护:提示重复、改动分散、变量插入也不够直观。与其等到后面全面失控,不如在这个阶段就把提示内容抽到专门目录里。

services/api/src/llm/prompts/
├─ requirement.prompt.ts
├─ classify.prompt.ts
└─ retrieve-answer.prompt.ts

3.5.2 最小提示模板的定义

有了目录结构之后,先定义一组最小模板,把“系统角色”与“用户输入”从一开始就分开。

services/api/src/llm/prompts/requirement.prompt.ts

export const REQUIREMENT_SYSTEM_PROMPT = `
你是一名“需求结构化抽取助手”。

你的任务是:
从输入文本中提取结构化字段。

严格要求:
1. 不允许编造信息
2. action 必须是唯一核心动作(动词+对象)
3. constraints 只保留明确约束(必须 / 至少 / 不得 / 不能)
4. entities 只提取文本中真实出现的名词
5. 如果不存在某字段,返回空数组

输出必须符合 schema,不要输出解释
`.trim();

export const REQUIREMENT_USER_TEMPLATE = `
请抽取结构化信息:

输入:
{input}
`.trim();

3.5.3 ChatPromptTemplate.fromMessages() 的作用

模板常量定义好之后,接下来用 ChatPromptTemplate 把 system 与 human 两段消息组装成可复用提示。

services/api/src/llm/requirement.prompt-builder.ts

import { ChatPromptTemplate } from '@langchain/core/prompts';
import {
  REQUIREMENT_SYSTEM_PROMPT,
  REQUIREMENT_USER_TEMPLATE,
} from './prompts/requirement.prompt';

export const requirementPrompt = ChatPromptTemplate.fromMessages([
  ['system', REQUIREMENT_SYSTEM_PROMPT],
  ['human', REQUIREMENT_USER_TEMPLATE],
]);

3.5.4 模板渲染结果的查看

在真正把模板送给模型之前,最好先单独验证一次渲染结果。这样可以先确认变量替换和消息结构是否正确,再继续往后接。

Service 新增方法(只渲染模板,不调模型):

import { requirementPrompt } from './requirement.prompt-builder';

async promptPreview(input: string) {
  const promptValue = await requirementPrompt.invoke({ input });
  return { rendered: promptValue.toString() };
}

Controller 新增路由:

@Post('prompt-preview')
async promptPreview(@Body() body: { input: string }) {
  return this.llmService.promptPreview(body.input);
}

3.5.5 将模板转换为消息数组

当模板本身确认无误之后,再把它转换成消息数组并传给模型。这样整条调用路径就从“手写消息”切换成了“模板 → 消息 → 模型”。

Service 新增方法(模板 → 消息 → 模型):

async promptToModel(input: string) {
  const messages = await requirementPrompt.formatMessages({ input });
  const response = await this.model.invoke(messages);
  return { result: response.content };
}

Controller 新增路由:

@Post('prompt-to-model')
async promptToModel(@Body() body: { input: string }) {
  return this.llmService.promptToModel(body.input);
}

curl 验证:

curl -s -X POST http://localhost:3001/api/langchain/stream \
  -H "Content-Type: application/json" \
  -d '{"input": "用户注册时必须绑定手机号,密码至少8位"}'

原始图片缺失占位图

到这里,提示层就从“临时拼接”变成了“可复用模板”。接下来要解决的,就是如何把模板、模型和解析稳定地串成一条固定流程。


3.6 基础调用链的构建

3.6.1 pipe() 的链式组合能力

当步骤开始稳定地按同样顺序出现时,就不该继续把它们分散写在各个地方了。这时更合适的做法,是用 pipe() 把它们明确串成一条链。

generated-image-1774765273638.png

3.6.2 StringOutputParser 的接入

最小调用链通常由三段组成:提示模板、模型、输出解析。这里先接入一个最基础的 StringOutputParser,把模型输出统一转成纯字符串。

services/api/src/llm/requirement.chain.ts

import { StringOutputParser } from '@langchain/core/output_parsers';
import { createChatModel } from './model.factory';
import { requirementPrompt } from './requirement.prompt-builder';

const model = createChatModel();
const parser = new StringOutputParser();

export const requirementChain = requirementPrompt.pipe(model).pipe(parser);

3.6.3 chain.invoke() 的调用方式

链建好之后,最先用到的仍然是 invoke()。只不过这时你调用的,已经不再是“一个模型”,而是一条固定流程。

Service 新增方法:

import { requirementChain } from './requirement.chain';

async chainInvoke(input: string) {
  const result = await requirementChain.invoke({ input });
  return { result };
}

Controller 新增路由:

@Post('chain-invoke')
async chainInvoke(@Body() body: { input: string }) {
  return this.llmService.chainInvoke(body.input);
}

curl 验证:

curl -s -X POST http://localhost:3001/api/langchain/stream \
  -H "Content-Type: application/json" \
  -d '{"input": "用户注册时必须绑定手机号,密码至少8位"}'

image 3.png

3.6.4 chain.stream() 的流式执行

如果同一条固定流程需要边执行边返回,那么也没必要退回手写拼接。链本身同样支持 stream()

Service 新增方法:

async chainStream(input: string) {
  return requirementChain.stream({ input });
}

Controller 新增路由(SSE 方式):

@Post('chain-stream')
async chainStream(@Body() body: { input: string }, @Res() res: Response) {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const stream = await this.llmService.chainStream(body.input);

  for await (const chunk of stream) {
    res.write(chunk);
  }

  res.end();
}

3.6.5 chain.batch() 的批量执行

同理,如果这条标准流程需要在多条输入上重复执行,那么也可以直接切到 batch()

Service 新增方法:

async chainBatch(inputs: string[]) {
  const results = await requirementChain.batch(
    inputs.map((input) => ({ input }))
  );
  return results.map((result, i) => ({ index: i + 1, result }));
}

Controller 新增路由:

@Post('chain-batch')
async chainBatch(@Body() body: { inputs: string[] }) {
  return this.llmService.chainBatch(body.inputs);
}

3.6.6 链式调用的具体使用场景

到这一节,重点已经不再是“单独会不会用 promptmodelparser”,而是:当一组步骤总是以同样顺序反复出现时,就应该把它们收束成一条可以重复执行的链。

和 3.4 里直接调用模型相比,3.6 更适合解决这类问题:

因此,链最适合承接的,不是“一次性的临时调用”,而是那些已经开始稳定、会被反复复用的最小业务流程

chain.invoke():适合固定流程的一次性结果返回

当你的场景不是“裸调模型”,而是“模板 → 模型 → 解析”这一整套流程都必须稳定执行时,chain.invoke() 会成为最自然的默认选择。

一句话概括,chain.invoke() 适合:结果要完整返回,而且这次返回背后其实已经有一条固定流水线。

chain.stream():适合同一条流程需要边产出边展示

如果这条链本身已经稳定,但输出内容较长、用户等待时间较明显,或者前端希望边生成边展示,那么就适合把同一条链切到 stream()

也就是说,chain.stream() 不是为了改变业务逻辑,而是为了在同一条稳定流程上优化用户体验。

chain.batch():适合批量跑同一条标准处理链

当同一个处理链要在多条输入上重复执行时,chain.batch() 的价值就会非常明显。它解决的不是“能不能跑”,而是“怎样更成批、更统一地跑”。

在工程实践里,chain.batch() 往往是把“能跑的 AI 功能”推进到“可规模化处理的数据流程”的关键一步。

什么时候优先上链,而不是继续手写分散调用?

可以用一个很直接的判断标准:如果你已经连续两次以上写出“同一个 prompt + 同一个 model + 同一个 parser”的组合,就应该考虑把它收成 chain。

因为从这个时点开始,你真正要维护的,已经不再是某次调用,而是一条业务流程。

所以,3.6 的核心意义并不是“会用 pipe() 这个 API”,而是学会识别:

当你后面继续往结构化输出、工具调用、检索增强推进时,这种“先收束成链,再逐步加能力”的思路会越来越重要。


3.7 结构化输出与程序化消费

3.7.1 自由文本输出的局限

前面几节已经把调用流程整理得更清楚了,但如果模型输出最终还是一段自由文本,那么它进入程序后的价值仍然有限。只要结果要作为接口返回值、下游输入或测试断言的一部分,自由文本就迟早会暴露出不稳定的问题。

cleaned-image_(3).png

3.7.2 共享字段结构的定义

因此,下一步要做的,就是先把字段结构明确下来,并沉淀到共享 contract 中。

定义在 packages/contracts/src/index.ts

import { z } from 'zod';

export const RequirementSchema = z.object({
  input: z.string().min(1),
});

export const RequirementResultSchema = z.object({
  action: z.string().describe('唯一核心动作'),
  constraints: z.array(z.string()).describe('明确约束条件'),
  entities: z.array(z.string()).describe('关键实体'),
});

export type RequirementResult = z.infer<typeof RequirementResultSchema>;

3.7.3 withStructuredOutput() 的使用方式

有了共享 schema 之后,就可以让模型直接朝着这个结构输出,而不是再依赖后置字符串清洗。

services/api/src/llm/requirement.service.ts

import { Injectable } from '@nestjs/common';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import {
  RequirementResultSchema,
  type RequirementResult,
} from '@repo/contracts';
import { createChatModel } from './model.factory';
import {
  REQUIREMENT_SYSTEM_PROMPT,
  REQUIREMENT_USER_TEMPLATE,
} from './prompts/requirement.prompt';

@Injectable()
export class RequirementService {
  private model = createChatModel();

  private prompt = ChatPromptTemplate.fromMessages([
    ['system', REQUIREMENT_SYSTEM_PROMPT],
    ['human', REQUIREMENT_USER_TEMPLATE],
  ]);

  async extract(input: string): Promise<RequirementResult> {
    const messages = await this.prompt.formatMessages({ input });
    const structuredModel = this.model.withStructuredOutput(
      RequirementResultSchema
    );
    return structuredModel.invoke(messages);
  }
}

3.7.4 结构化输出的工程价值

到这里,模型结果才真正开始具备“可被程序稳定消费”的条件。

Controller 新增路由:

@Post('structured')
async structured(@Body() body: { input: string }) {
  return this.requirementService.extract(body.input);
}

curl 进行验证:

curl -s -X POST http://localhost:3001/api/langchain/structured \
  -H "Content-Type: application/json" \
  -d '{"input": "用户注册时必须绑定手机号,密码至少8位"}'

返回结果:

image 4.png

也正是在这一层之后,LangChain 才开始真正和“工程化”发生关系:因为只有结构稳定,测试、回归、复用和下游编排才会真正成立。


3.8 工具调用机制的接入

3.8.1 工具的本质:可调用函数

做到结构化输出之后,同一段需求文本已经能被整理成稳定字段了。但模型本身依然只能“生成内容”。如果你希望它在抽取之后还能主动校验约束、补充规则说明,就需要把工具接进来。

原始图片缺失占位图

这里先接两个最小工具,目的不是把场景做复杂,而是帮助你看清:模型负责决策,工具负责提供确定性结果。

services/api/src/llm/tools/basic.tools.ts

import { z } from 'zod';
import { tool } from '@langchain/core/tools';

export const checkConstraintValidityTool = tool(
  async ({ constraint }: { constraint: string }) => {
    const passed = /必须|至少|不得|不能/.test(constraint);
    return {
      constraint,
      passed,
      reason: passed ? '命中明确约束模式' : '不属于明确约束表达',
    };
  },
  {
    name: 'check_constraint_validity',
    description: '校验一条约束是否属于明确约束表达',
    schema: z.object({
      constraint: z.string(),
    }),
  }
);

export const lookupEntityDefinitionTool = tool(
  async ({ entity }: { entity: string }) => {
    const map: Record<string, string> = {
      用户: '系统中的账号主体',
      手机号: '用于身份绑定与验证的联系字段',
      密码: '用于登录认证的安全凭证',
    };

    return {
      entity,
      definition: map[entity] ?? '未命中内置定义',
    };
  },
  {
    name: 'lookup_entity_definition',
    description: '查询实体在业务中的定义说明',
    schema: z.object({
      entity: z.string(),
    }),
  }
);

3.8.2 bindTools() 的绑定方式

工具定义好之后,下一步就是把它们绑定到模型上。

Service 新增方法:

import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import {
  checkConstraintValidityTool,
  lookupEntityDefinitionTool,
} from './tools/basic.tools';

async toolBindDemo(input: string) {
 const modelWithTools = this.model.bindTools([
    checkConstraintValidityTool,
    lookupEntityDefinitionTool,
  ]);

  const response = await modelWithTools.invoke([
    new SystemMessage('你可以按需要调用工具来校验约束和查询实体定义。'),
    new HumanMessage(`请分析下面需求:${input}`),
  ]);

  return {
    result: response.content.toString(),
    toolCalls: response.tool_calls as ToolCall[],
  };
}

3.8.3 tool_calls 返回结构的理解

当模型绑定工具后,它不一定会立刻给出最终答案。很多时候,它会先通过 tool_calls 声明:它准备调用哪个工具、参数是什么、调用 id 是多少。

这一步非常关键,因为从这里开始,模型就不再只是“生成一段文本”,而是在参与一个可审计的执行流程。

3.8.4 工具执行闭环的手动实现

真正有工程价值的,不是“模型会不会声明调用工具”,而是你能否把工具执行闭环完整接起来。

Service 新增方法(完整工具执行闭环):

import {
  HumanMessage,
  SystemMessage,
  ToolMessage,
  type BaseMessage,
} from '@langchain/core/messages';

async toolLoopDemo(input: string) {
  const tools = [checkConstraintValidityTool, lookupEntityDefinitionTool];
  const toolMap = Object.fromEntries(tools.map((t) => [t.name, t]));
  const modelWithTools = this.model.bindTools(tools);

  const messages: BaseMessage[] = [
    new SystemMessage('你可以调用工具来帮助完成需求抽取后的校验。'),
    new HumanMessage(
      `先抽取 action、constraints、entities,再按需要调用工具:${input}`
    ),
  ];

  const firstResponse = await modelWithTools.invoke(messages);
  messages.push(firstResponse);

  for (const toolCall of firstResponse.tool_calls ?? []) {
    const targetTool = toolMap[toolCall.name];
    if (!targetTool) continue;
    const toolResult = await targetTool.invoke(toolCall.args);
    messages.push(
      new ToolMessage({
        tool_call_id: toolCall.id,
        content: JSON.stringify(toolResult),
      })
    );
  }

  const finalResponse = await modelWithTools.invoke(messages);
  return { result: finalResponse.content };
}

curl 验证:

curl -s -X POST http://localhost:3001/api/langchain/tool-bind \
  -H "Content-Type: application/json" \
  -d '{"input": "用户注册时必须绑定手机号,密码至少8位"}'

返回结果:

image 5.png

3.8.5 工具调用适合解决什么问题

这一节真正要抓住的判断是:当答案依赖外部事实、外部规则或真实动作时,就该上工具;否则优先用 prompt / 结构化输出解决。

工具调用的分工可以概括为一句话:模型负责“决定是否调用、调用哪个、何时继续”,工具负责“给出确定性结果”。

常见场景可以浓缩成四类:

当你接受了这个分工,后面再进入 RAG、MCP 或 Agent runtime 时,理解成本就会低很多:因为这些能力的本质,也是在进一步扩展“模型负责判断,系统负责执行”的边界。


3.9 能力收束与统一入口

前面几个小节里,我们有意把能力拆开来看:模型调用、提示模板、链式组合、结构化输出、工具调用。到了这一节,就该把这些分散示例重新收束回真正的业务入口。

3.9.1 按业务域拆分 Service

当能力开始变多时,Service 的拆分方式也该逐渐从“技术演示”切换到“业务域承载”。因此,这里不再继续使用 SummaryService 之类的泛化命名,而是直接落到真实业务名:RequirementService

services/api/src/llm/requirement.service.ts

import { Injectable } from '@nestjs/common';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import {
  RequirementResultSchema,
  type RequirementResult,
} from '@repo/contracts';
import { createChatModel } from './model.factory';
import {
  REQUIREMENT_SYSTEM_PROMPT,
  REQUIREMENT_USER_TEMPLATE,
} from './prompts/requirement.prompt';

@Injectable()
export class RequirementService {
  private model = createChatModel();

  private prompt = ChatPromptTemplate.fromMessages([
    ['system', REQUIREMENT_SYSTEM_PROMPT],
    ['human', REQUIREMENT_USER_TEMPLATE],
  ]);

  async extract(input: string): Promise<RequirementResult> {
    const messages = await this.prompt.formatMessages({ input });
    const structuredModel = this.model.withStructuredOutput(
      RequirementResultSchema
    );
    return structuredModel.invoke(messages);
  }
}

3.9.2 统一入口 Controller

Service 按业务域收束之后,Controller 也应同步收口成统一入口,而不是继续暴露一堆演示性质的路由。

services/api/src/app.controller.ts

import { Body, Controller, Post } from '@nestjs/common';
import { RequirementService } from './llm/requirement.service';

@Controller()
export class AppController {
  constructor(private readonly requirementService: RequirementService) {}

  @Post('/requirement/extract')
  async extract(@Body() body: { input: string }) {
    return this.requirementService.extract(body.input);
  }
}

测试文件:

services/api/test/requirement.spec.ts

import { RequirementService } from '../src/llm/requirement.service';

describe('Requirement Extract', () => {
  const service = new RequirementService();

  it('should extract correctly', async () => {
    const result = await service.extract(
      '用户注册时必须绑定手机号,密码至少8位'
    );

    expect(result.action).toBe('用户注册');
    expect(result.constraints).toContain('必须绑定手机号');
    expect(result.entities).toContain('手机号');
  });
});

3.9.3 前端页面的职责边界

服务端入口收束完成后,前端的职责反而会变得更清楚:它不负责理解模型,不负责解析结构,也不负责规则校验,只负责输入、请求与展示。

clients/web/app/page.tsx

'use client';

import { useState } from 'react';

export default function Home() {
  const [input, setInput] = useState(
    '用户注册时必须绑定手机号,密码至少8位'
  );
  const [result, setResult] = useState<any>(null);

  async function handleSubmit() {
    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);
  }

  return (
    <main>
      <h1>Requirement Extract Demo</h1>

      <textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        rows={8}
      />

      <button onClick={handleSubmit}>提取</button>

      <pre>{JSON.stringify(result, null, 2)}</pre>
    </main>
  );
}

image 6.png

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

而模型规则、结构化抽取、工具调用、测试与校验,统统留在服务端处理。前端越薄,后端能力迭代时需要联动改动的地方就越少。

到这里,本章前面拆开的所有能力——模型调用、提示模板、链式编排、结构化输出、工具调用——才算真正收束成了一个业务上可用、可测试、可持续迭代的入口。


3.10 本章小结

这一章从头到尾只做了一件事:把"调用一次模型"逐步升级成"一条可维护的服务端能力链路"。整个过程围绕同一个业务任务——从需求文本中抽取 action / constraints / entities——递进展开,每一步都在上一步的基础上解决一个具体的工程问题。

回顾:每一层解决了什么问题

本章的核心收获

比记住 API 名称更重要的,是建立起几条可以反复使用的判断标准:

后续章节的衔接

本章建立的这套递进节奏——先定义任务、再逐层叠加能力、最后收束成接口——会贯穿后面所有章节。区别只在于,后面要叠加的能力会从"单链路"扩展到更复杂的维度:

但无论后面走多远,核心原则不会变:先把任务定义清楚,再把能力放到正确的层,最后收束成可测试、可迭代的入口。 只要这条线不断,系统的复杂度就始终有归属,而不会退回到"到处散落、无法维护"的状态。

写在最后🧪

这里是言萧凡的 AI 编程实验室。 我会在这里持续记录和分享 AI 工具、编程实践,以及那些值得沉淀下来的高效工作方法。 不只聊概念,也尽量分享能直接上手、能够复用的经验。 希望这间小小的实验室,能陪你一起探索、实践和成长。2026 年,一起进步。

有兴趣的话可以添加我的微信号一起交流,不仅是编程也可以是畅谈人生。