从零搭建自己的简单 Agent App
前两章你想清楚了“谁负责”和“怎么跑”,这一章动手搭出来。你仍是那个服务台:同一个“演示服务在线吗”,从单节点一路做到工具、模型、监听,再放进 NestJS。这一篇做一个“演示服务状态问答”App。先用一个节点跑通,再让它读取工具结果、交给模型组织回答,并接收运行中的状态。所有阶段都使用同一种 defineWorkflow → new AgentCoreEngine → agent.run 写法。
需要直接生成独立工程时,按创建应用使用 SDK 的 Node/TypeScript 初始化工具。它提供程序入口、真实工具授权、空资料和离线用例;本文继续解释各个执行阶段如何组合。
教程的默认版本完全离线:没有 API Key,不访问服务,不需要数据库。离线 Provider 只根据固定数据生成字符串,用来理解执行机制;它不代表真实模型的语义能力。
需要调试模型服务接入时,可直接使用 SDK 的 MockGPT Kit:直接生成、注入 Provider 或启动最小 Node 服务,复用同一组模拟规则。无需单独安装模拟器。
1. 准备环境与 SDK
使用 Node.js 22.12 或更新版本。创建应用并从 npm 安装依赖:
npm init -y
npm pkg set type=module
npm install @jangts/agent-core依赖由包管理器按包声明安装。使用本地分发包或离线包源时,见安装与接入。
JavaScript 示例保存为 .mjs,可以直接运行。TypeScript 示例保存为 .ts,可用项目自己的编译流程;独立目录可以安装编译器后运行:
npm install --save-dev typescript @types/node
npx tsc --module nodenext --target es2022 --strict --outDir dist app.ts
node dist/app.js以下代码中的泛型和类型导入只影响 TypeScript。JS 版本具有相同的调用与返回约定。
2. 先跑通一个节点
先不求答得多聪明,先把一条最细的链路跑通。将下面内容保存为 app.ts 或 app.mjs,此时先固定回答,把 Workflow、状态更新和最终交付的关系看清楚。
import { AgentCoreEngine, defineWorkflow, route } from '@jangts/agent-core';
type State = { question: string; answer?: string };
const workflow = defineWorkflow<State, string>({
id: 'service-helpdesk',
nodes: [{
id: 'answer',
description: '回答演示服务状态',
run: () => ({ answer: '演示服务在线。' }),
routes: [route.deliver('answer')],
}],
});
const agent = new AgentCoreEngine({ workflow });
const answer = await agent.run({ question: '演示服务在线吗?' });
console.log(answer);import { AgentCoreEngine, defineWorkflow, route } from '@jangts/agent-core';
const workflow = defineWorkflow({
id: 'service-helpdesk',
nodes: [{
id: 'answer',
description: '回答演示服务状态',
run: () => ({ answer: '演示服务在线。' }),
routes: [route.deliver('answer')],
}],
});
const agent = new AgentCoreEngine({ workflow });
const answer = await agent.run({ question: '演示服务在线吗?' });
console.log(answer);运行 node app.mjs,预期输出:
演示服务在线。这里的最终结果就是字符串。question 是初始状态;节点只返回它新增的 answer;交付路由读取该字段。没有手工配置入口节点时,首个节点就是入口。
3. 增加一个模型绑定,运行方式不变
现在能答了,但答案是写死的字。你想让产出经过模型接口、又不推翻上一步的写法,就绑定一个模型:让同一个 answer 节点通过模型接口生成文本。离线 Provider 接收普通模型消息,按流返回文本。
将上一步文件替换为下面版本。可以对照看见:新加的是 Provider 和构造时的模型绑定,节点内部多了一次 model.invoke;agent.run({ question }) 与取最终结果的方式都没变。
import { AgentCoreEngine, defineWorkflow, route } from '@jangts/agent-core';
import type { ChatProvider } from '@jangts/agent-core/types';
type State = { question: string; answer?: string };
const provider: ChatProvider = {
async *stream(messages, context) {
context.signal.throwIfAborted();
yield `已收到问题:${messages.at(-1)?.content ?? ''}`;
},
};
const workflow = defineWorkflow<State, string>({
id: 'service-helpdesk',
nodes: [{
id: 'answer',
description: '通过模型接口回答',
async run(state, { model }) {
const { value } = await model.invoke({ input: state.question });
return { answer: value };
},
routes: [route.deliver('answer')],
}],
});
const agent = new AgentCoreEngine({ workflow, model: { provider, maxCalls: 1 } });
console.log(await agent.run({ question: '演示服务在线吗?' }));import { AgentCoreEngine, defineWorkflow, route } from '@jangts/agent-core';
const provider = {
async *stream(messages, context) {
context.signal.throwIfAborted();
yield `已收到问题:${messages.at(-1)?.content ?? ''}`;
},
};
const workflow = defineWorkflow({
id: 'service-helpdesk',
nodes: [{
id: 'answer',
description: '通过模型接口回答',
async run(state, { model }) {
const { value } = await model.invoke({ input: state.question });
return { answer: value };
},
routes: [route.deliver('answer')],
}],
});
const agent = new AgentCoreEngine({ workflow, model: { provider, maxCalls: 1 } });
console.log(await agent.run({ question: '演示服务在线吗?' }));model.invoke 返回 { value, invocation },这里把 value 写回状态。模型调用回执与 App 的最终 Result 属于不同合同:当前 Workflow 最终仍交付字符串。
4. 让回答依据真实执行的工具
现在它能答了,但答的是模型复述的字,不是真正查到的状态。想让它说出真正查到的状态,下一步把查询做成工具,让回答依据真实回执——这里的“真实”指确实经过工具执行和解析,而不是把模型说的“已查询”当成回执。工具使用离线常量,不访问生产服务。
为后面的 CLI 与 NestJS 复用,将固定定义移入 service-agent.ts/service-agent.mjs。createServiceAgent 是这个示例 App 自己的装配函数,不是 SDK 的新入口。
import { AgentCoreEngine, Support, defineWorkflow, route } from '@jangts/agent-core';
import type { ChatProvider, ExecutionOptions } from '@jangts/agent-core/types';
type Status = { status: 'online'; latencyMs: number };
type State = { question: string; service?: Status; answer?: string };
// 固定定义只写一次;这个离线公开资料工具不需要用户身份。
const tools = Support.defineToolCatalog([{
id: 'get_status',
kind: 'read',
description: '读取演示服务的当前状态',
inputSchema: {
jsonSchema: { type: 'object', properties: {}, additionalProperties: false },
parse(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).length !== 0) throw new TypeError('状态查询不接受参数');
return {};
},
},
authorize() {
// 仅适用于本例的公开离线数据;真实用户数据必须在这里校验授权。
},
execute: (): Status => ({ status: 'online', latencyMs: 12 }),
parseOutput(value: unknown): Status {
if (!value || typeof value !== 'object') throw new TypeError('状态结果无效');
const status = value as Partial<Status>;
if (status.status !== 'online' || typeof status.latencyMs !== 'number'
|| !Number.isFinite(status.latencyMs) || status.latencyMs < 0) {
throw new TypeError('状态结果无效');
}
return { status: status.status, latencyMs: status.latencyMs };
},
}]);
const provider: ChatProvider = {
async *stream(messages, context) {
context.signal.throwIfAborted();
// 固定模拟协议,仅用于离线教程;不是语义理解或事实校验器。
const input = JSON.parse(messages.at(-1)?.content ?? '{}');
yield `演示服务${input.service.status === 'online' ? '在线' : '不可用'},延迟 ${input.service.latencyMs}ms。`;
},
};
const workflow = defineWorkflow<State, string>({
id: 'service-helpdesk',
nodes: [
{
id: 'lookup',
description: '执行状态查询工具',
provides: ['service'],
async run(_state, { tools, report }) {
report({ payload: { phase: 'lookup', message: '正在查询服务状态' } });
const { value } = await tools.invoke({ toolId: 'get_status', input: {} });
return { service: value as Status };
},
routes: [route.to('answer')],
},
{
id: 'answer',
description: '根据查询回执组织回答',
requires: ['service'],
provides: ['answer'],
async run(state, { model }) {
const { value } = await model.invoke({
input: JSON.stringify({ question: state.question, service: state.service }),
});
return { answer: value };
},
routes: [route.deliver('answer')],
},
],
});
export function createServiceAgent(execution: ExecutionOptions = {}) {
const runtime = tools.bind({ app: {} });
return new AgentCoreEngine({
workflow,
model: { provider, maxCalls: 1 },
maxRounds: 1,
execution,
}).bindToolRuntime(runtime);
}import { AgentCoreEngine, Support, defineWorkflow, route } from '@jangts/agent-core';
const tools = Support.defineToolCatalog([{
id: 'get_status',
kind: 'read',
description: '读取演示服务的当前状态',
inputSchema: {
jsonSchema: { type: 'object', properties: {}, additionalProperties: false },
parse(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).length !== 0) throw new TypeError('状态查询不接受参数');
return {};
},
},
authorize() {
// 仅适用于本例的公开离线数据;真实用户数据必须在这里校验授权。
},
execute: () => ({ status: 'online', latencyMs: 12 }),
parseOutput(value) {
if (!value || typeof value !== 'object' || value.status !== 'online'
|| typeof value.latencyMs !== 'number' || !Number.isFinite(value.latencyMs)
|| value.latencyMs < 0) throw new TypeError('状态结果无效');
return { status: value.status, latencyMs: value.latencyMs };
},
}]);
const provider = {
async *stream(messages, context) {
context.signal.throwIfAborted();
const input = JSON.parse(messages.at(-1)?.content ?? '{}');
yield `演示服务${input.service.status === 'online' ? '在线' : '不可用'},延迟 ${input.service.latencyMs}ms。`;
},
};
const workflow = defineWorkflow({
id: 'service-helpdesk',
nodes: [
{
id: 'lookup',
description: '执行状态查询工具',
provides: ['service'],
async run(_state, { tools, report }) {
report({ payload: { phase: 'lookup', message: '正在查询服务状态' } });
const { value } = await tools.invoke({ toolId: 'get_status', input: {} });
return { service: value };
},
routes: [route.to('answer')],
},
{
id: 'answer',
description: '根据查询回执组织回答',
requires: ['service'],
provides: ['answer'],
async run(state, { model }) {
const { value } = await model.invoke({
input: JSON.stringify({ question: state.question, service: state.service }),
});
return { answer: value };
},
routes: [route.deliver('answer')],
},
],
});
export function createServiceAgent(execution = {}) {
const runtime = tools.bind({ app: {} });
return new AgentCoreEngine({
workflow,
model: { provider, maxCalls: 1 },
maxRounds: 1,
execution,
}).bindToolRuntime(runtime);
}这个例子在工具输出解析后才接受状态。TypeScript 中的 as Status 只把低层工具调用返回的 unknown 关联到本例已经校验的结果,不承担运行时校验;真正校验在 parseOutput。
这里没有让模型决定要不要调用工具:Workflow 明确规定先查询、再回答。要添加模型选择能力,可以继续登记模型 capability 和传输,再让已有节点把结构化选择写入状态;工具的真实执行和授权仍沿相同合同。
5. 原生 Node.js:增加监听,不改变问答入口
现在回答有了真实依据,但同事在群里看不到过程。下一步加个监听,把“查到哪一步”露出来,问答入口不动。
创建 CLI 入口。两种语言都导入上一节的装配函数:
import { createServiceAgent } from './service-agent.js';
const agent = createServiceAgent({
observe: {
accepts: ({ event, content }) => content === 'metadata' || event === 'node.progress',
event(fact) {
if (fact.type === 'node.progress') console.log('[进度]', fact.payload.payload);
if (fact.type === 'model.start') console.log('[进度] 正在组织回答');
},
},
});
const answer = await agent.run({ question: '演示服务在线吗?' });
console.log(answer);import { createServiceAgent } from './service-agent.mjs';
const agent = createServiceAgent({
observe: {
accepts: ({ event, content }) => content === 'metadata' || event === 'node.progress',
event(fact) {
if (fact.type === 'node.progress') console.log('[进度]', fact.payload.payload);
if (fact.type === 'model.start') console.log('[进度] 正在组织回答');
},
},
});
const answer = await agent.run({ question: '演示服务在线吗?' });
console.log(answer);预期会先看到节点进度、模型开始,最后看到:
演示服务在线,延迟 12ms。没有配置持久日志也能观察过程。节点只报告内容,执行身份由 Core 提供。这里为了演示明确接收了 node.progress 内容;不要为了显示一个忙碌状态,默认收集完整提示词、工具输入或用户资料。
取消仍通过运行选项提供,不需要重建 Workflow:
const controller = new AbortController();
controller.abort(new Error('用户停止'));
try {
await agent.run({ question: '演示服务在线吗?' }, { signal: controller.signal });
} catch (error) {
console.log('本次运行已停止');
}const controller = new AbortController();
controller.abort(new Error('用户停止'));
try {
await agent.run({ question: '演示服务在线吗?' }, { signal: controller.signal });
} catch (error) {
console.log('本次运行已停止');
}这个例子在进入运行前取消,用于确认边界。真实 HTTP 或 UI 适配器应在用户断开、停止或期限到达时触发对应信号,并把同一信号传给依赖。
6. NestJS:复用定义,按请求隔离执行
跑通了,但它还是命令行里的一次性脚本。下一步放进 NestJS:复用同一份定义,按请求隔离执行。已有 NestJS 工程不需要换一套 SDK 写法。把 createServiceAgent 作为应用装配函数导入,在单例服务内发起每次执行即可。
本例每次请求创建轻量的 Engine 和工具运行时,固定 Workflow、工具目录和无请求状态的 Provider 由模块复用。observer、diagnostics 和工具授权上下文绑定到各自请求,保持请求之间的生命周期隔离。
import { Injectable, Module } from '@nestjs/common';
import { createServiceAgent } from './service-agent.js';
@Injectable()
export class AgentService {
async ask(question: string, signal?: AbortSignal): Promise<string> {
const agent = createServiceAgent();
return agent.run({ question }, { signal });
}
}
@Module({ providers: [AgentService], exports: [AgentService] })
export class AgentModule {}import { Injectable, Module } from '@nestjs/common';
import { createServiceAgent } from './service-agent.mjs';
export class AgentService {
async ask(question, signal) {
const agent = createServiceAgent();
return agent.run({ question }, { signal });
}
}
Injectable()(AgentService);
export class AgentModule {}
Module({ providers: [AgentService], exports: [AgentService] })(AgentModule);JS 版本使用 Nest 的装饰器函数调用,因为普通 .mjs 不直接支持 TypeScript 装饰器语法。两者导出相同的服务和模块。
在已有控制器中注入 AgentService 并调用 ask。控制器负责 HTTP 输入校验、用户身份、额度、断开信号及响应协议;AgentService 负责 App 装配;Workflow 负责业务流程。不要把一个绑定了第一位用户 signal、权限或日志目标的 ModelRunner 放进全局单例后给所有人共用。
如果工具依赖本次用户身份,应在 App 的装配函数中把经过服务端认证的上下文交给 tools.bind({ app, execution }),并在工具 authorize 中检查。不要直接把客户端传入的“管理员”标记当成授权。
7. 接入真实模型时,只替换 Provider
离线这一路全通了,就差把假模型换成真模型。下一步只替换 Provider,不碰其它。离线流程跑通后,可把 service-agent 中的 Provider 换成你实际配置的适配器。不要改变 Workflow 的 model.invoke、状态更新或最终交付方式。
以下示例使用 Support 提供的兼容 Chat Completions 适配器,具体 URL、模型名和能力必须来自你的供应商配置:
import { Support } from '@jangts/agent-core';
const { AGENT_MODEL_BASE_URL, AGENT_MODEL_API_KEY, AGENT_MODEL_NAME } = process.env;
if (!AGENT_MODEL_BASE_URL || !AGENT_MODEL_API_KEY || !AGENT_MODEL_NAME) {
throw new Error('请先提供实际模型连接配置');
}
const provider = new Support.CompatibleModelProvider({
config: {
baseUrl: AGENT_MODEL_BASE_URL,
apiKey: AGENT_MODEL_API_KEY,
model: AGENT_MODEL_NAME,
},
});import { Support } from '@jangts/agent-core';
const { AGENT_MODEL_BASE_URL, AGENT_MODEL_API_KEY, AGENT_MODEL_NAME } = process.env;
if (!AGENT_MODEL_BASE_URL || !AGENT_MODEL_API_KEY || !AGENT_MODEL_NAME) {
throw new Error('请先提供实际模型连接配置');
}
const provider = new Support.CompatibleModelProvider({
config: {
baseUrl: AGENT_MODEL_BASE_URL,
apiKey: AGENT_MODEL_API_KEY,
model: AGENT_MODEL_NAME,
},
});离线示例的 Provider 将受控 JSON 转成字符串。真实模型需要由 App 提供明确的指令,将工具回执作为数据使用,并检查最终回答是否满足业务要求;不要把这份离线模板当成已经验证过的生产提示词或供应商能力测试。
API Key 留在服务端环境配置中,不放到浏览器包或提交记录。真实工具要把示例常量替换为实际服务调用,并执行输入输出和授权检查。
继续增加需求时,从哪一处加
| 新需求 | 在现有结构中的增加位置 |
|---|---|
| 查询失败后解释错误 | 节点 onErrorNodeId 与明确的错误节点;取消不作为成功兜底 |
| 根据状态选择不同回答 | 同一节点的 routes,可用 route.toIfEqual 或原始路由对象 |
| 让模型选择可用能力 | 已登记 capability、schema 和传输;选择结果进入普通节点与路由 |
| 加入有来源的知识 | App 选择 Knowledge 等 Kit,读取快照后供节点使用 |
| 输入超出预算 | 加入明确的上下文处理节点,继续使用同一模型调用和最终预算检查 |
| 部分实现改成自定义类 | 实现相同节点/Provider/工具合同,直接接入,不强制转成普通对象 |
| 记录调用链 | App 注册允许的日志输出并持有关联,节点不自行管理日志树 |
你可以从分层 API 目录检查具体输入、输出和推荐策略。增加功能时按需补充配置,已有的定义、绑定、run 和取结果方式可以继续使用。
使用 MockGPT 调试模型接入
接真模型前,先用 MockGPT 把接线验一遍。教程的普通对象 Provider 适合理解节点。需要同时检查 HTTP/SSE、分片和 Core 解析边界时,使用 Kit 的 fetch 适配,Workflow 与 Agent 的调用方式保持不变。
import { ModelRunner, Support } from '@jangts/agent-core';
import { MockGPT } from '@jangts/agent-core/mock';
const mock = new MockGPT({ rules: [{ generator: '演示服务在线。' }] });
const provider = new Support.CompatibleModelProvider({
config: { baseUrl: 'https://mock.local/v1', apiKey: 'offline', model: 'demo' },
ports: { request: mock.fetch },
});
console.log((await new ModelRunner({ provider }).invoke('服务状态')).value);import { ModelRunner, Support } from '@jangts/agent-core';
import { MockGPT } from '@jangts/agent-core/mock';
const mock = new MockGPT({ rules: [{ generator: '演示服务在线。' }] });
const provider = new Support.CompatibleModelProvider({
config: { baseUrl: 'https://mock.local/v1', apiKey: 'offline', model: 'demo' },
ports: { request: mock.fetch },
});
console.log((await new ModelRunner({ provider }).invoke('服务状态')).value);需要让其他进程调用同一模拟模型时,从 @jangts/agent-core/mock/node 导入 createMockServer。最小服务、故障序列和可信网页来源 load 示例见 MockGPT Kit及示例与策略。