不懂后端,我如何“手搓”全栈类 Manus 智能体

我特别喜欢通过做项目来学习。为了开始学习后端知识,我觉得利用业余时间做一个在线全栈智能体应用是极好的,目前第一次迭代已经开源。本次实践的目标是以 OPC 的角色,力求用最短的时间完成 MVP 版本并准备好投入商业验证。我们希望实现一个类似 Manus 的智能体应用,因此我给它起名叫“Tinymanus”。它的商业竞争力很低,但是体验一次迭代的过程可以使我们在面临真正的商业需求时拥有更游刃有余的技术能力。第一次迭代的目标是完成产品简单而完整的功能,具体来说:

  • 允许用户创建智能体,由智能体调用工具并给出最终结果,但不允许中途修改/追加提示词。
  • 提供让智能体运行 JavaScript 脚本的功能并控制智能体可访问的目录,但没有沙盒隔离设计。
  • 一个账户可以创建多个智能体,每个智能体可以运行多个 run,每次 run 就代表一次用户给智能体指派的任务。
  • 有可用的 credit 系统,提供注册赠送积分功能和运行 run 时消耗积分的功能,但不接入真正的支付网关。
  • 提供可扩展性良好的注册登录系统,但本版本只支持邮箱密码登录,无邮箱验证和找回密码功能。

这些特性看起来不难,但也不是一句提示词就能“变”出来的。有关技术细节如下:

智能体的“手”和“脚”:无状态智能体能力

智能体能做的事是复杂的:读写文件、运行脚本、读浏览器。看起来我们的后端 App“有手有脚”的,不止是一个 CRUD 机器,但是如果我们把要给智能体提供的能力拆解出来,剩下的部分就又尽是 CRUD 了。所以在开始设计全栈 App 时就需要先回答一个问题:它都需要拥有什么样的能力,这些能力能不能拆解为单独的模块。Tinymanus 后端除了监听 HTTP 端口和操作数据库以外,还需要与外界作如下交互:

  • 发起 OpenAI 格式的 LLM 请求(LLM 是智能体的灵魂呀)
  • 允许智能体在特定目录下读写文件,供智能体读写脚本、存取数据等
  • 运行由智能体编写的 JavaScript 脚本,以便爬取数据、处理数据等
  • 提供 LLM 可访问的浏览器并使 LLM 像人类一样操作浏览器的组件

这些能力都可以封装为单独的小型库,每项能力的实现限定在目录以内,这样程序的其他部分和智能体都不需要关注这些能力的实现细节了。同时,它们不依赖于业务及目录以外的部分,可以直接复制到其他项目里。我们的产品目录结构目前如下:

tinymanus/
└── src/
    └── modules/
        ├── llm_gateway/
        ├── workspace/
        ├── js_runner/
        ├── browser_session/
        └── browser_tools/

LLM 统一网关

LLM 统一网关负责与 OpenAI API 进行交互,将智能体的请求转换为 OpenAI 格式的请求,将 OpenAI 返回的响应转换为智能体需要的格式。为了降低对 OpenAI 格式的耦合性,我们必须对 OpenAI 请求参数做一层封装,将消息统一抽象成 systemuserassistanttool 四种角色,将模型回复统一抽象为文本、工具调用、token usage 和 finish reason,并将流式响应统一抽象成文本增量、工具调用增量和结束事件。单条消息可以抽象如下:

type LlmMessage = {
  role: "system" | "user" | "assistant" | "tool";
  // 现在只支持文本,预留多模态功能
  content: string | { type: "text"; text: string }[];
  // 推理文本和结果分开
  reasoningContent?: string;
  name?: string;
  // 工具调用上下文
  toolCallId?: string;
  toolCalls?: LlmToolCall[];
};

export type LlmToolCall = {
  id: string;
  type: "function";
  function: {
    name: string;
    // 参数这里用 JSON
    arguments: string;
  };
};

第一次接入大模型时,我们可能觉得只要兼容 OpenAI 协议就行了。这对第一轮请求来说还好,但到了智能体场景就未必了,因为智能体是多轮循环:模型先规划,再发起工具调用,工具执行后把结果回填给模型,模型再继续推理。问题通常出在第三步,首轮请求成功,并不能证明整条链路兼容。这里的网关不只负责“发请求”,还要负责把 assistant tool calls 和 tool role message 原样、完整、按顺序序列化回上游。对于智能体系统来说,LLM 网关最重要的职责之一,就是保证这个多轮上下文的线性可续传性。

有些 reasoning model 会把推理内容和最终回答拆开返回,甚至在后续 continuation 时要求把之前的推理内容重新回传。这个时候,如果网关只保留用户可见文本,把 reasoning 丢了,系统就会进入一种很隐蔽的失败状态:第一轮成功,第二轮报错,而且错误信息还未必能直接说明根因。因此不论是否希望给用户展示推理文本,reasoningContent 字段都是必不可少的。

工具调用方面,我们采用带命名空间的工具标识(如 workspace.read_textbrowser.click),这种命名对系统内部很好,因为一眼就知道工具属于哪个模块,也天然避免重名,但有些 provider 对函数名字符集或格式有限制,带点号的名字会出现不被接受的情况。于是网关还要承担转换工具名称的功能:请求发出去之前,把内部工具名编码成 provider-safe 的标识;模型返回工具调用后,再把它解码回内部命名。编码规则如下:

export const PROVIDER_SAFE_TOOL_NAME_PREFIX = "tool_";

export function encodeProviderToolName(toolName: string): string {
  return `${PROVIDER_SAFE_TOOL_NAME_PREFIX}${Buffer.from(toolName, "utf8").toString("hex")}`;
}

原始工具名(如 workspace.read_text)按 UTF-8 转为二进制 buffer,然后按十六进制编码,最后在前面添加 tool_ 前缀级得到处理过的工具名(如 tool_776f726b73706163652e726561645f74657874)。

LLM 调用链路里的错误来源一共有三层。第一层是 401、429、500 等明确的 HTTP 错误,对于这一类错误读取响应体并抛出带状态码的异常就够了。第二层是 provider 的业务错误,表面上可能也是 400,但真正根因可能是 continuation 消息格式不对、工具调用字段缺失、上下文序列不合法。第三层是传输错误,比如流式连接中途被断开、fetch 被底层终止、超时或者上游半途中止响应。如果所有错误都只是统一抛出一句“request failed”的话排障体验就会非常差,所以网关层最好做两件事:一个是把 HTTP 层错误包装成带状态码和原始响应体的 typed error,另一个是把底层 transport error 归一化成更稳定的错误语义。前者表示“请求被拒绝了”,后者表示“连接层出了问题”。

LLM 网关在本迭代还做了简单的流式处理功能。流式返回通过 SSE 把数据分成很多小块持续推送过来,这里最容易踩坑的地方在于网络层收到的 chunk 边界并不等于 SSE 事件边界,SSE 事件边界也不等于一段完整的模型语义边界。也就是说,网关需要自己维护一个增量解析过程。Tinymanus 的 llm_gateway 会先从 response.body 里持续读取字节流,用 TextDecoder 把字节块解码成文本,再把文本追加到一个缓冲区里。随后网关会把缓冲区中的 \r\n 统一归一成 \n ,再按照 SSE 的事件分隔符 \n\n 切分事件块。如果最后一段还不完整,就先保留在缓冲区里,等下一次读取到更多数据后再继续拼接。这一步的目的很明确:避免因为 chunk 恰好截断在半条事件或半个 JSON 中间,导致解析提前失败。切出一个个 SSE 事件块之后,网关只提取其中的 data: 行,把多行 data: 合并成真正的 payload。遇到 [DONE] 时,说明上游流已经正常结束,网关这时会把先前累计的文本、推理内容和工具调用统一收口,产出最终的完整响应。对于普通文本增量,处理逻辑比较直接:每收到一段 delta.content ,就把它追加到当前文本上,同时向上层发出一个 text-delta 事件,前端或日志系统就可以边到边消费。

这套流式逻辑的关键在于边界条件。比如换行风格可能不一致、chunk 可能恰好截断在 JSON 中间、工具调用可能先给函数名后给参数,参数本身也可能被拆成很多片段,结束事件 [DONE] 也未必总按最理想的时机到达。所以从工程的角度看,流式传输需要在 SSE 字节流之上逐步重建一次完整的 LLM 响应:既要拼文本,也要拼推理内容,还要把碎片化的工具调用恢复成可执行的结构,所以这部分逻辑值得单独封装和单独测试。本次迭代没有做完整的逐个 token 输出的功能,其中 content 文本、tool_call 为增量处理,允许其他模块实时处理 delta.content,而 reasoning_content 为思考结束后全量处理。

agent 工作区

Tinymanus 初期版本并没有实现完整的文件沙箱,不过做了简单的路径隔离设计,适合小范围内部测试。workspace 提供处理路径安全的功能,它为智能体提供一套受约束的虚拟文件系统接口,透过这一接口,智能体就无法感知真实文件路径了。我们将 run 的工作目录映射为 ~,将 agent 共享目录映射为 ~/.agent,这就是智能体允许“活动”的空间。LLM 希望操作的路径首先会被解析为结构化对象:

type WorkspaceResolvedPath = {
  // LLM 操作的是 run 目录还是 agent 共享目录
  alias: "run" | "agent";
  // LLM 感知的虚拟路径,如 '~/src/index.ts'
  virtualPath: string;
  // 上面的文件落到宿主机上的真实绝对路径
  realPath: string;
  // 相对 run 目录或 agent 共享目录的相对路径,如 'src/index.ts'
  relativePath: string;
};

// 这个类型表示查询一个目录所返回的文件列表的一项
type WorkspaceDirectoryEntry = {
  name: string;
  kind: "file" | "directory" | "other";
  virtualPath: string;
  realPath: string;
};

解析时,所有外部输入都必须以 ~~/.agent 开头,其他前缀一律拒绝。模块还显式防御了目录穿越:像 ~/../../etc/passwd~/.agent/../../secret.txt 这种路径,即使绕过前一层,真正拼接真实路径时还会再做一次“结果必须仍然落在根目录之内”的检查。一旦 agent 尝试访问非法路径,在规范化相对路径阶段就会抛出 WorkspacePathError。本模块对程序其他部分暴露以下接口:

class Workspace {
  constructor(config: WorkspaceConfig)

  getRoots(): WorkspaceConfig
  resolvePath(virtualPath: string): WorkspaceResolvedPath
  toVirtualPath(realPath: string): string

  ensureRoots(): Promise<void>
  ensureDirectory(virtualPath: string): Promise<WorkspaceResolvedPath>

  exists(virtualPath: string): Promise<boolean>
  readTextFile(virtualPath: string): Promise<string>
  writeTextFile(
    virtualPath: string,
    content: string,
    options?: WorkspaceWriteFileOptions,
  ): Promise<WorkspaceResolvedPath>
  deletePath(
    virtualPath: string,
    options?: WorkspaceDeleteOptions,
  ): Promise<void>
  listDirectory(virtualPath: string): Promise<WorkspaceDirectoryEntry[]>
}

workspace 模块为 LLM 提供以下工具,这些工具的参数只接受虚拟路径:

  • workspace.list_directory
  • workspace.read_text
  • workspace.write_text
  • workspace.delete_path

JavaScript 运行时

和文件沙箱一样,本次迭代 Tinymanus 没有实现隔离的代码执行环境。我们为智能体提供一个脚本执行适配器,它对外提供几类执行 JS 的逻辑:

// 解析路径
type JsRunnerWorkspace = {
  resolvePath: (virtualPath: string) => WorkspaceResolvedPath;
};

// 执行脚本的通用参数
type JsRunnerExecutionOptions = {
  // 虚拟工作目录
  cwdPath?: string;
  env?: Record<string, string>;
  stdinText?: string;
  timeoutMs?: number;
};

// 直接执行 JS 指令的参数
type JsRunnerCommandRequest = JsRunnerExecutionOptions & {
  workspace: JsRunnerWorkspace;
  command: string;
  args?: string[];
};

// 执行 workspace 下的 JS 脚本的参数
type JsRunnerFileRequest = JsRunnerExecutionOptions & {
  workspace: JsRunnerWorkspace;
  command: string;
  filePath: string;
  args?: string[];
};

// JS 执行结果
type JsRunnerExecutionResult = {
  command: string;
  args: string[];
  cwdPath: string;
  targetPath?: string;
  stdout: string;
  stderr: string;
  exitCode: number | null;
  signal: NodeJS.Signals | null;
  timedOut: boolean;
  durationMs: number;
};

// 对外的执行接口
interface JsRunner {
  runCommand(request: JsRunnerCommandRequest): Promise<JsRunnerExecutionResult>;
  runFile(request: JsRunnerFileRequest): Promise<JsRunnerExecutionResult>;
}

此模块为 LLM 提供的接口如下:

  • js_runner.run_command
  • js_runner.run_file

runCommand()runFile() 做的第一件事是请求归一化。它们接受一个 cwdPath 参数,这样智能体就可以更方便地指定工作目录、避免把不同事项的文件混在一起。真正执行脚本要靠另一个接口:

// 真实执行脚本的请求参数
type ResolvedJsRunnerRequest = {
  command: string;
  args: string[];
+ cwdRealPath: string;
  cwdVirtualPath: string;
+ targetRealPath?: string;
  targetVirtualPath?: string;
  env?: Record<string, string>;
  stdinText?: string;
  timeoutMs?: number;
};
// 真实执行脚本的接口
interface JsRunnerBackend {
  execute(request: ResolvedJsRunnerRequest): Promise<JsRunnerBackendResult>;
}

JsRunnerBackend 为内部接口,与对外接口解耦,方便迭代时替换相关实现(如引入 docker 等)。目前提供的后端实现是 LocalProcessJsRunnerBackend,它首先校验工作目录是否存在,然后通过 Node 的 child_process.spawn() 启动本地子进程,并把 stdout 、 stderr 都收集成字符串返回。js_runner 允许 LLM 使用 fetch 等 Node 18+ 内置的能力,但不预装第三方库。

浏览器

浏览器能力分为两层,在两个不同的模块中分别实现:

  • browser_session:负责管理浏览器会话,提供打开网页、点击、输入、截图等能力
  • browser_tools:负责把访问浏览器包装成 LLM 工具

browser_session 通过 Playwright 操作 Chromium 浏览器,提供以下接口:

// 启动浏览器会话的参数
export type BrowserSessionStartOptions = {
  sessionId?: string;
  headless?: boolean;
  userAgent?: string;
  // {width: number, height: number}
  viewport?: BrowserSessionViewport;
  defaultTimeoutMs?: number;
  launchArguments?: string[];
  allowPrivateHosts?: boolean;
};

// 浏览器会话的页面状态
export type BrowserSessionPageState = {
  sessionId: string;
  url: string;
  title: string;
};

interface BrowserSession {
  // 启动浏览器会话
  start(options?: BrowserSessionStartOptions): Promise<BrowserSessionPageState>;
  openPage(request: BrowserSessionOpenPageRequest): Promise<BrowserSessionPageState>;
  // 获取当前页面状态
  getPageState(): Promise<BrowserSessionPageState>;
  // 获取页面快照
  createSnapshot(): Promise<BrowserSessionSnapshot>;
  extractText(options?: BrowserSessionExtractTextOptions): Promise<string>;
  clickElement(request: BrowserSessionClickRequest): Promise<BrowserSessionPageState>;
  typeIntoElement(request: BrowserSessionTypeRequest): Promise<BrowserSessionPageState>;
  waitFor(request: BrowserSessionWaitRequest): Promise<BrowserSessionPageState>;
  saveScreenshot(request: BrowserSessionScreenshotRequest): Promise<BrowserSessionScreenshotResult>;
  close(): Promise<void>;
}

其中,页面快照将 HTML 页面浓缩成 LLM 更易于理解的摘要,避免直接发送大段 HTML 代码:

export type BrowserSessionLinkSummary = {
  text: string;
  href: string;
};

export type BrowserSessionButtonSummary = {
  text: string;
  // selectorHint 按出现的顺序可以依次是 id、name、aria-label、data-testid,最终是 tag name
  selectorHint: string;
};

export type BrowserSessionInputSummary = {
  type: string;
  name: string;
  placeholder: string;
  selectorHint: string;
};

type BrowserSessionSnapshot = BrowserSessionPageState & {
  textPreview: string;
  links: BrowserSessionLinkSummary[];
  buttons: BrowserSessionButtonSummary[];
  inputs: BrowserSessionInputSummary[];
};

获取摘要步骤如下:

  1. 调用 getPageState() 获取 sessionId、URL 和标题
  2. 执行 page.evaluate() 获取页面元素的文本内容、链接、按钮、输入框等信息
  3. document.body.innerText 中提取页面可见文本,压缩空白字符并取前 4000 字符
  4. 选择 a[href] 并提取 text、href 属性,取前 20 个
  5. 选择 buttoninput[type="button"]input[type="submit"][role="button"]),并提取 text、selectorHint 属性
  6. 选择 input:not([type="hidden"])textareaselect),并提取 type、name、placeholder、selectorHint 属性

这一层做了 URL 校验,只允许打开 HTTP 或 HTTPS 协议的 URL,默认不允许访问本地地址。此外,截图保存的产物也复用 workspace 模块,接受虚拟路径。browser_tools 提供以下 LLM 能力:

  • browser.open
  • browser.snapshot
  • browser.extract_text
  • browser.click
  • browser.type
  • browser.wait
  • browser.screenshot
  • browser.close

持久化数据与数据库设计

infrastructure/ 目录提供后端程序的基础功能,访问持久化数据的逻辑就应当装在该目录下。持久化数据使用 PostgreSQL 数据库存储,使用 Prisma 进行 ORM 访问。

tinymanus/
├── prisma/               # 数据库存储的数据结构和表的定义
└── src/
    ├── modules/
    └── infrastructure/
        ├── data_access/  # 将数据库操作数据库的函数封装为业务接口
        └── database/     # 封装 Prisma 客户端方法

数据库表设计共分以下几个部分。第一部分是用户与认证,核心表包括:

  • User:用户主表,存展示信息和用户状态,包括 displayNameavatarUrlstatuslastLoginAt 等字段
  • UserIdentity:登录身份表,一个用户对应多个身份源,目前仅支持邮箱登录,但可扩展至其他登录方式
  • VerificationChallenge:验证码、找回密码、绑定身份这类登录尝试记录,记录目标、过期时间、已使用状态和尝试次数等
  • Session:登录以后的服务端会话表,数据库里只存 tokenHash
  • AuthAuditLog:认证审计日志,记录注册、登录、登出、重置密码、绑定/解绑身份等动作是否成功,以及来源 IP、UA、provider 等审计信息

第二部分是积分与账务:

  • CreditAccount:积分账户快照表,保存用户当前积分余额和账户状态,适合高频读取当前积分余额
  • CreditTopUp:积分充值/赠送订单表,记录充值、赠送的来源、金额、幂等键、外部参考号和最终状态
  • CreditLedger:积分流水账表,记录每次积分变化的加减方向、金额、变更后余额以及业务原因,如 SIGNUP_BONUSRUN_CONSUMPTION

第三部分是 agent 与对话容器,包括:

  • Agent:智能体定义,包括名称、描述、system prompt、model、状态和配置
  • Thread:对话线程,用来承载一个用户和某个 agent 的会话容器
  • Message:线程里的消息,可以关联某次 Run,也能记录 tool 相关消息字段

其中,同一个 Agent 可以有多个 Thread ,同一个 Thread 又可以挂多次 Run ,对话历史和执行历史既有关联,又不完全耦合。

第四部分是运行执行链路。核心表 Run 保存运行的汇总状态(即当前状态、输入、agent 快照、最终结论、结果摘要,比如 statusfinalConclusionresultSummary)及少量失败上下文字段(如 requiredUserActionblockedPlanItemIdfailureSummary)。比较细节的过程性数据则落在附属表里:

  • RunEvent:运行事件表,保存运行过程中的关键状态事件,比如 run.createdrun.startedrun.waiting_for_user,适合给前端做时间线和 SSE 同步
  • RunPlanItem:记录 agent 生成的 plan 及其状态变化,每一项有标题、描述、顺序号和状态,用来表达“准备做哪些步骤、做到哪一步了”
  • RunTraceEntry:记录执行过程中的用户输入、观察、工具结果、中间结论和错误
  • ToolInvocation:记录每次工具调用的输入输出、耗时和结果状态,包含工具名、输入输出、错误信息、开始/结束时间及耗时
  • Artifact:记录截图、stdout、stderr、抽取文本、文件等产物

主表适合提供接口,附属表适合调试、追踪与回放。这套表构成了智能体运行状态持久化存储的基础,其中快照表和追加型历史表是明确分开的,一个用于用户接口读取,一个用于调试追踪。

日志系统:切面设计

Logger 对象的组织

Tinymanus 对日志系统的切面设计较为轻量,首先定义统一的 Logger 接口,然后在 HTTP 入口、数据库访问层、后台 worker、应用服务这些边界位置统一注入和包装日志逻辑。日志模块的核心接口如下:

type LogLevel = "debug" | "info" | "warn" | "error";

type LogFields = Record<string, unknown>;

interface Logger {
  child(bindings: LogFields): Logger;
  debug(message: string, fields?: LogFields): void;
  info(message: string, fields?: LogFields): void;
  warn(message: string, fields?: LogFields): void;
  error(message: string, fields?: LogFields): void;
}

child() 方法允许我们在当前的 logger 对象上创建一个子 logger。子 logger 在创建时会继承父 logger 的上下文,同时我们还可以继续追加上下文字段,这样我们可以更精确地控制每个 logger 所带的上下文信息:

const serverLogger = appLogger.child({
  module: "server",
  role: "api",
});

const requestLogger = appLogger.child({
  module: "http",
  requestId: request.id,
  method: request.method,
  url: request.url,
});

Logger 的服务范围

logger 在打印时会自动带上其所存储的上下文信息,比每次打印都手动添加要方便很多。Tinymanus 在不同场景都是需要设计日志记录切面的,首先是 HTTP 请求切面,具体来说需要在 onRequestonResponseonError 三个 hook 里统一记录请求生命周期:

  • request.started
  • request.completed
  • request.failed

请求开始时创建一个带 requestIdmethodurl 的子 logger,并把开始时间挂到 request 对象上;请求结束时计算 durationMs,记录状态码;请求失败时记录错误对象。这样所有路由都天然有统一的请求日志,不需要每个 handler 自己写一遍开始、结束、失败日志。第二层是数据库访问切面,数据访问层统一继承 DataAccessBase ,所有仓储方法都通过 execute() 包一层:

protected async execute<T>(
  operation: string,
  fields: LogFields | undefined,
  // 具体的数据库操作
  action: () => Promise<T>,
): Promise<T> {
  const startedAt = Date.now();

  this.logger.debug("data_access.operation.started", {
    operation,
    ...fields,
  });

  try {
    const result = await action();

    this.logger.info("data_access.operation.succeeded", {
      operation,
      durationMs: Date.now() - startedAt,
      ...fields,
    });

    return result;
  } catch (error) {
    this.logger.error("data_access.operation.failed", {
      operation,
      durationMs: Date.now() - startedAt,
      ...fields,
      error,
    });

    throw error;
  }
}

具体的数据库访问操作就要使用 execute() 方法包装:

return this.execute("getRunById", { runId }, async () => {
  return this.client.run.findUnique({
    where: { id: runId },
  });
});

第三层是业务层日志,包括登录、管理 run、充值等业务服务。这里采用名词-动词-形容词命名:

  • auth.login.started
  • auth.login.succeeded
  • run.create.started
  • run.create.succeeded
  • credit.top_up.confirm.succeeded

第四层是进程级日志,它的重要作用是对未处理异常进行兜底,一旦出现未处理异常,进程级 logger 会打出 process.unhandled_rejection 或 process.uncaught_exception 日志。

日志输出

每条日志都会输出成一行 JSON,默认带上 timestamplevelservicemessage 字段,所需的额外字段可通过结构化对象传入。相比字符串拼接,这种格式更适合后续接日志平台、按 runIduserIdrequestIddurationMs 等字段检索。实现里还专门处理了 ErrorDatebigint、数组、嵌套对象和循环引用,避免日志写入时因为对象无法 JSON 序列化而再次出错。一条典型的日志输出如下(实际输出时应当拍成一行):

{
  "module": "http",
  "requestId": "req-1",
  "method": "POST",
  "url": "/v1/agents",
  "statusCode": 201,
  "durationMs": 23,
  "timestamp": "2026-07-13T10:00:00.000Z",
  "level": "info",
  "service": "tinymanus",
  "message": "request.completed"
}

根据日志等级,debug、info 日志写入 stdout,warn、error 日志写入 stderr。目前的日志不会持久化保存,用于审计、恢复的事件记录使用上面的 SQL 表记录。现在我们的目录结构如下:

tinymanus/
├── prisma/
└── src/
    ├── modules/
    └── infrastructure/
        ├── logger/       # 日志记录逻辑
        ├── data_access/
        └── database/

鉴权链路:注册与登录

注册与登录校验、数据库事务

虽然 Tinymanus 此版本只做了邮箱+密码登录,但是账户与电子邮箱并没有耦合在一起,其中账户使用 User 表存储,邮箱(identityType = EMAIL, credentialType = PASSWORD)及未来的其他账户登录方式使用 UserIdentity 表存储。注册链路的入口是 signupWithEmailPassword()。它首先会对邮箱做标准化处理,首先去掉首尾空格,并转成小写作为 identifierNormalized 用于唯一性判断,而展示用的原始邮箱则保存在 identifier 字段里,而密码当前只做了要求至少 8 位的长度校验。注册时,系统会先按 EMAIL + normalizedEmail 查询是否已经存在身份,如果已经存在,就写一条失败的 AuthAuditLog,然后返回 EMAIL_ALREADY_REGISTERED。除了应用层检查,数据库层也有唯一约束:

@@unique([identityType, identifierNormalized])

在两个层面确保了不会有两个账户绑定同一个邮箱。注册事务的步骤如下:

  1. 创建 User,状态为 ACTIVE
  2. 创建 UserIdentity,使用邮箱密码登录
  3. 创建 CreditAccount
  4. 根据配置的注册赠送积分的情况,创建 CreditLedger 记录
  5. 创建一条注册成功的 AuthAuditLog 记录

登录链路的入口是 loginWithEmailPassword()。它同样先标准化邮箱,然后查找对应的 UserIdentity。登录过程中会依次检查几件事:

  1. 身份是否存在
  2. 这个身份是否支持邮箱密码登录(即配置了相应 UserIdentity)
  3. 身份状态是否为 ACTIVE
  4. 用户状态是否为 ACTIVE
  5. 校验密码是否通过

任意一步失败都会写一条登录失败的 AuthAuditLog,但对用户返回时,邮箱不存在和密码错误都使用 INVALID_CREDENTIALS 表示,避免暴露出“这个邮箱是否注册过”的信息。每次登录成功时会创建并返回 token,并向数据库写入登录事务,步骤如下:

  1. 更新 User.lastLoginAt 为当前时间
  2. 创建 Session,保存当前状态(包括 tokenHash 等字段)
  3. 创建一条登录成功的 AuthAuditLog 记录

登录系统的可扩展性很好。如果以后要加手机号+验证码登录/第三方 OAuth 登录,在 UserIdentity 表里添加对应的类型字段 identityType 就可以了。

密码校验

密码校验通过并准备存储时首先运用 Node 的 crypto.scrypt 模块进行 hash 流程:

  1. 生成 16 字节随机 salt
  2. 使用 scrypt(password, salt, 64) 派生 64 字节密钥
  3. 把算法名、salt、派生密钥拼成一个 scrypt$<salt>$<derivedKey> 格式的字符串
  4. 将该字符串存入 UserIdentity.secretHash

数据库只能保存密码 hash,不能保存明文密码,以防数据库泄露的情况。每个用户的 salt 是随机生成的,即使两个用户使用了同一密码,最终存储的 hash 很可能仍然不同,加盐设计有力地提高了彩虹表攻击的成本。登录时校验用户密码的步骤如下:

  1. secretHash 中拆出算法名、salt 和已存储的派生密钥
  2. 用用户本次输入的密码和同一个 salt 再跑一次 scrypt 生成用户提供的派生密钥
  3. 使用 timingSafeEqual 对比两个密钥

其中 timingSafeEqual 会尽可能使用恒定的时间对比密钥,避免攻击者利用比较密钥的时间差实施时序攻击。

token 分发校验

用户登录通过时,系统生成非透明令牌(opaque token)并将其返回给客户端。token 经 SHA-256 哈希后在创建 session 时存储在 Session.tokenHash 字段里。HTTP 层登录成功后,会把原始 token 写入 Cookie:

tinymanus_session=<rawToken>; Path=/; HttpOnly; SameSite=Lax; Expires=...; Max-Age=...

后续操作时,客户端需要在请求头里添加带有 session token 的 cookie。服务器在请求头中读取 token,进行鉴权流程:

  1. 计算 token 的 SHA-256 hash
  2. 查询 Session 表,根据 tokenHash 查找对应的 session,并筛选出 status = ACTIVEexpiresAt > now 的结果
  3. 根据 session.userId 得到用户 id
  4. 检查用户是否存在、状态是否为 ACTIVE
  5. 在鉴权通过时更新 Session.lastUsedAt 为当前时间
  6. 把认证上下文({user, session, identityId})挂到当前请求上,供路由 handler 使用

Tinymanus 没有使用 JWT,因为无信息 token 实现随时撤销更加容易,同时利用数据库 Session 表反查用户状态的设计使得无信息 token 使用起来对 Tinymanus 来说没有任何不方便的地方。到这里我们的目录结构已经发展成了这样:

tinymanus/
├── prisma/
└── src/
    ├── modules/
    ├── infrastructure/
    │   ├── logger/
    │   ├── data_access/
    │   ├── database/
    │   └── security/     # token 生成、哈希、校验工具函数
    └── application/      # 核心数据链路,采用名词-动词的格式对下面的目录结构命名
        └── auth/         # 认证鉴权流程

双层 HTTP API 与后端框架选型

Express、KOA、NestJS……Node 有形形色色的后端框架。之前我们使用 TS 实现了大量后端通用逻辑,现在我们要把精力放在后端 HTTP 框架的选型与 API 接口设计上了。Tinymanus 的核心复杂度体现在智能体调度、任务状态管理与执行上面,HTTP 请求/访问相比之下较为轻量。项目大量使用 TypeScript 编写,大部分逻辑与辅助工具均为通用 TS 逻辑,模块边界较为清晰。我们选择轻量级的 Fastify 框架为项目提供 HTTP API 功能,使用 Schema 绑定接口,这样我们就可以把更多精力放在业务逻辑上了。我们的 HTTP 接口采用了一种比较克制的双层设计,下面一层是与框架解耦合的通用 HTTP 层,上面一层是 Fastify 适配层,前者负责和具体 Web 框架无关、但又确实属于 HTTP 边界的问题,后者负责把这些能力接到 Fastify 的请求、响应、路由和 schema 机制上。这样做可以把 HTTP 语义和 Fastify API 分开,不让业务和协议细节被框架实现绑死。

tinymanus/
├── prisma/
└── src/
    ├── modules/
    ├── infrastructure/
    ├── application/
    ├── interfaces/            # 程序对外提供的接口
    │   └── http/              # HTTP 接口,貌似也就有 HTTP 接口
    │       ├── core/          # 框架无关的接口实现,目前有鉴权
    │       └── fastify/       # Fastify 适配层
    │           ├── auth/      # 认证鉴权相关接口
    │           ├── routes/v1/ # 路由相关接口
    │           ├── schema/    # Fastify schema 定义
    │           └── errors/    # 把错误对象映射成非 200 响应
    └── app/api/               # 创建 Fastify 应用实例、通用 hook 配置、路由注册

通用 HTTP 层还比较简单,但边界很明确,主要聚焦在认证这类最容易被框架细节污染的逻辑上,包括 session cookie 的名字、序列化方式和清除方式、从原始 Cookie header 解析认证上下文的过程、未登录时应该返回的标准 challenge 等细节问题。处理 cookie 的输入是非常原始的 cookieHeader?: string ,输出则是框架无关的 ResolveCurrentSessionResult | null 或标准化的 401 challenge。这一层并不知道 FastifyRequest 是什么,更不会去调用 reply.code(401) 这类框架 API。鉴权时,这一层的 auth 模块的工作是解析具体的 cookie 文本并解析 session,在报错时则抛出通用性更强的 HttpAuthError

在这之上,Fastify 层做的事情就是“把通用能力翻译进 Fastify”。最典型的例子是 fastify/auth/http-auth.ts,它一从 request.headers.cookie 中取出原始 Cookie header,调用通用层的 requireHttpAuthContext(),同时另一方面又把拿到的认证结果挂回当前 Fastify request 供后续 handler 使用。于是经包装以后,最终暴露给路由层的就只剩下了 httpAuth.requireAuth pre-handler 和读取 { user, session, identityId }httpAuth.getAuthContext(request)(即读取鉴权解析结果)。这样,这一层只负责和路由有关的部分,不直接参与业务。

路由本身放在 src/interfaces/http/fastify/routes/v1 目录下,它们做的工作如下:

  1. 绑定请求 schema
  2. 使用 httpAuth.requireAuth 鉴权
  3. 调用业务逻辑函数
  4. 将业务错误通过 error mapper 映射为请求错误

src/app/api/ 目录负责构建 Fastify 应用实例、各路由的注册、日志 hook 切面和服务器错误响应收口。在请求开始时,通用日志 hook 创建一个带 requestIdmethodurl 上下文的子 logger,并把开始时间挂到 request 对象上;请求结束时计算 durationMs,记录状态码;请求失败时记录错误对象。这样所有路由都天然有统一的请求日志,每次开始、结束、失败时的信息都会自动记录,路由层不需要编写日志记录逻辑。

充值与积分链路

涉及钱的部分总要加倍小心。我们的用户积分变动记录用前面提过的三个表来实现“余额快照 + 追加式账本 + 充值单状态机”的三层模型管理:

  • CreditAccount:积分账户快照表,保存用户当前积分余额和账户状态,适合高频读取当前积分余额
  • CreditTopUp:积分充值/赠送订单表,记录充值、赠送的来源、金额、幂等键、外部参考号和最终状态
  • CreditLedger:积分流水账表,记录每次积分变化的加减方向、金额、变更后余额以及业务原因,如 SIGNUP_BONUSRUN_CONSUMPTION

CreditAccount 是一个用户一行,userId 是主键,主要字段是 balancestatusCreditLedger 是真正的资金流水,每次变更都新增一条,不覆盖历史,关键字段包括 directionamountbalanceAfterreasonreferenceTypereferenceId。这意味着系统不仅知道“余额现在是多少”,还知道“为什么变成这样”。而 CreditTopUp 保存的是一笔 top-up 请求本身:充值多少、来源是什么、幂等键是什么、外部参考号是什么、目前处于什么状态、何时确认成功。这样一来,充值订单和资金入账就被明确区分开了。

创建充值单和确认入账被设计成两个不同的逻辑。在 CreditTopUp 表中,每个充值记录有以下几种状态:PENDINGSUCCEEDEDFAILEDCANCELLED。充值创建时,默认状态为 PENDING,并不会立即更新用户积分余额。调用方在调用充值请求时必须提供每单唯一的幂等键,系统就会按照 (userId, idempotencyKey) 查找是否已经存在同一笔单据。确认充值成功需要经过以下流程:

  1. 先读取 CreditTopUp ,并校验这笔单据确实属于当前用户。
  2. 如果它已经是 SUCCEEDED ,说明之前已经处理过,这时直接返回当前账户和对应流水,不再重复加钱。
  3. 如果它不是 PENDING ,就拒绝确认,因为只有待确认状态才能入账。
  4. 在事务里尝试把 CreditTopUp.statusPENDING 更新成 SUCCEEDED
  5. 当这个“状态迁移”成功之后,继续读取或创建 CreditAccount
  6. 计算 balanceAfter = oldBalance + topUp.amount
  7. 更新 CreditAccount.balance
  8. 追加一条 CreditLedger ,记录:
  • direction = CREDIT
  • reason = TOPUP
  • referenceType = TOPUP
  • referenceId = topTopUp.id

消费积分时的逻辑在创建 Run、把任务塞到队列里后,会在同一个事物里读取当前 CreditAccount 校验积分是否足够,然后扣减一笔 RUN_CONSUMPTION,流程如下:

  1. 读取 CreditAccount
  2. 检查账户是否 ACTIVE
  3. 检查 balance >= RUN_START_CREDITS
  4. 计算 balanceAfter = balance - runStartCredits
  5. 更新 CreditAccount.balance
  6. 追加一条 CreditLedger
  • direction = DEBIT
  • reason = RUN_CONSUMPTION
  • referenceType = RUN
  • referenceId = run.id

由于消费和业务动作放进了同一个事务边界里,“run 创建成功但没扣钱”或者“钱扣了但 run 没创建成功”这类最麻烦的不一致问题不会出现。通过幂等键、待确认 top-up、条件状态迁移和事务内的余额更新 + 流水追加,充值链路可以尽量把重复请求、并发确认和部分失败带来的不一致风险收敛在模型边界之内。现在我们的目录结构如下:

tinymanus/
├── prisma/
└── src/
    ├── modules/
    ├── infrastructure/
    ├── application/
    │   ├── auth/
    │   └── credit/       # 积分管理相关业务逻辑
    ├── interfaces/
    ├── app/api/
    └── bin/

Worker 多进程架构、为什么不用 Redis(RabbitMQ、Kafka……)

进程设计的业务逻辑

对于 Node 服务器来说,IO 密集的单线程异步模型天然是最常见的。但是智能体运行天然不是一个短请求,它可能要多轮调用 LLM、写文件、跑脚本、开浏览器、规划与重规划以及持续状态记录。如果把这些长耗时逻辑直接塞进 API 进程,请求线程模型、超时控制、资源隔离和故障恢复都会变得复杂起来。我们把运行时依照职责拆分成 API 和 worker 两个部分,其中 API 进程保持“快进快出”,而 worker 进程专注消费队列、执行 run、维护 lease 和处理失败恢复。

为了承载这类后台任务,我们引入了一个基于 Postgres 的队列表 RunQueueItem。当创建 run 时,API 进程会在同一个事务内完成一整组前置操作,包括 agent 与余额校验、创建 Run、扣减积分、写入 RunEventRunTraceEntry,最终创建对应的 RunQueueItem。这样,run 的业务记录与后续调度入口就原子地串在了一起。

随后,worker 在准备执行新任务时,会通过 claimNextRun() 从队列中认领一条可执行任务,再调用 processClaimedRun() 正式开始处理。为了确保同一条任务在同一时刻只由一个 worker 负责,每条 RunQueueItem 都带有 leaseOwnerleaseExpiresAt 两个字段。worker 在认领任务时会更新这两个字段,相当于声明 “在这段时间内,这条任务由我负责处理”。如果某个任务执行时间较长,worker 还会定时续租,以维持这份处理权。这样如果某个 worker 意外崩溃或长时间卡死,lease 过期后,其他 worker 就可以重新认领这条任务,从而避免 run 永久卡在 “已被认领但无人完成” 的中间状态。

RunQueueItem 的状态包括 QUEUEDRUNNINGCOMPLETEDFAILEDWAITING_FOR_USERBLOCKEDCANCELLED。worker 会根据 run 的处理结果更新队列项本身:如果 run 正常结束,就调用 completeRunClaim(),将当前 RunQueueItem 标记为 COMPLETED;如果这是一次可恢复失败,就调用 releaseRunClaim(),将队列项重新放回 QUEUED,同时增加 attemptCount,等待后续重试;如果 run 被取消,则对应队列项会被标记为 CANCELLED。无论结果如何,在 run 处理结束后,worker 都会停止 lease heartbeat,然后返回外层轮询循环,继续认领下一条任务。

对于 MVP 产品来说,如果能少引入一个基础设施组件,部署就更简单,排障路径也更短,恰巧 Postgres 队列足以满足这类需求。与此同时,RunQueueItem 是系统需要持久保存、查询并恢复的真相源,直接采用数据库存储还有额外的一致性、状态可恢复的收益。

进程启动与日志切面

现在我们有两个程序入口(API 和 worker 进程)了。到现在为止的目录结构如下:

tinymanus/
├── prisma/
└── src/
    ├── modules/
    ├── infrastructure/
    ├── application/
    ├── interfaces/
    ├── app/api/               # API 进程启动 Fastify 应用的逻辑
    └── bin/                   # 启动各进程的入口
        ├── api.ts             # API 进程启动逻辑的实现
        ├── worker.ts          # worker 进程启动逻辑的实现
        ├── run-api.ts         # API 进程启动入口
        └── run-worker.ts      # worker 进程启动入口

进程启动时,首先创建子 logger 准备记录进程级日志:

// API 进程子 logger
const appLogger = createLogger({
  serviceName: "tinymanus",
});

// worker 进程子 logger
const appLogger = createLogger({
  serviceName: "tinymanus",
  bindings: {
    processRole: "worker",
  },
});

接下来是进程级异常处理器。两个进程都会注册处理 unhandledRejectionuncaughtException(异步异常)的处理器来处理未捕获异常,在遇到这类没有捕获的异常时,进程会记录 process.unhandled_rejectionprocess.uncaught_exception 类型的日志并退出。worker 会从以下环境变量读取启动参数:

  • RUN_WORKER_ID
  • RUN_WORKER_QUEUE_NAME
  • RUN_WORKER_POLL_INTERVAL_MS,默认 1000ms
  • RUN_WORKER_LEASE_TTL_MS,默认 30000ms

接下来 API 进程创建 Fastify 应用,这一过程会自动注册处理请求相关的日志 hook,而 worker 在创建 RunWorker 时会自动注册 run 相关的日志 hook,记录启动、停止、执行周期失败、lease 丢失、lease 续约失败等事件。worker 进程还实现了一套优雅停机逻辑,收到 SIGTERMSIGINT 信号后,会先调用 stopWorker() 再通过 shutdownController.abort() 退出,减小对 worker 和 lease 的状态的影响。

真正属于智能体的部分:plan-and-execute 三部曲

做了大量和传统意义上的后端相关的工作,现在我们终于可以关心智能体究竟如何运行了。这个 MVP 产品选用 Manus 曾经采用过的 plan-and-execute 流程,通过先规划、再逐步执行、最后单独汇总结论实现简单的结构化任务执行。智能体的工作流程拆分为三个部分:planner、executor 和 finalizer。

默认的 planner 会尝试调用 LLM,根据用户输入生成一组结构化 plan item。planner 的输出是一组 RunPlanItemDraft[],数组的每项都包含标题、描述,以及一个 metadataJson.kind 标记这是执行步骤、验证步骤还是最终回答步骤。应用层随后会把这些计划存入数据库的 RunPlanItem 行里,然后追加一条 PLAN_UPDATE trace 和 run.plan.initialized 事件。目前的 executor 严格按顺序执行每一条 plan item,每当进入一个 plan item 时,系统先把该步骤标记为 IN_PROGRESS,再写一条“started plan item”的 trace,然后调用 executor.executePlanItem() 执行。执行器向 LLM 给出系统提示词:“你正在执行当前这一步,可以按需调用 workspace、js_runner、browser 等工具”,随后开始多轮调用循环,每一轮将工具列表暴露给模型,在接收到 tool call 时执行工具并将结果返回给工具,直至模型给出此步的执行总结为止。每个 plan item 存在工具调用上限,在接近上限时,系统将提醒模型在工具调用次数耗尽时不得继续调用工具、必须给出收尾总结。按照这样的流程,executor 推进每个计划步骤并收集 trace、tool invovation 与 artifact 等证据。所有 plan item 都完成后,系统将调用 finalizer 收集 run 中 LLM 调用留下的 trace、工具调用记录和产物列表并请求 LLM 根据完整的执行轨迹给出最终的结论和结果摘要,然后在数据库记录 finalConclusionresultSummary 并追加 run.completed 事件、同步会话。

每个 plan item 执行完成后,应用层可以调用 planner.replan() 要求 planner 根据最新证据判断是否需要重新规划。当 planner 认为需要对后续步骤重新规划并给出新的步骤列表时,系统会把当前尚未执行的尾部 plan item 全部标记为 CANCELLED,再插入新的 RunPlanItem,同时写入 run.plan.replanned 事件和新的 PLAN_UPDATE trace。为了防止无限抖动,最多允许 2 次 replanning。现在我们的目录结构安排如下:

tinymanus/
├── prisma/
└── src/
    ├── modules/
    ├── infrastructure/
    │   ├── logger/
    │   ├── data_access/
    │   ├── database/
    │   ├── security/
    │   └── execution/    # 智能体实际执行的逻辑(plan-and-execute)
    ├── application/
    │   ├── auth/
    │   ├── credit/
    │   ├── run/          # Run 的创建、取消、串联 execution 的执行逻辑
    │   └── agent/        # 智能体的创建、管理等
    ├── interfaces/
    ├── app/api/
    └── bin/

任务状态机、容错设计

Run 目前可能是八种状态之一:QUEUEDRUNNINGWAITING_FOR_USERBLOCKEDPARTIALCOMPLETEDFAILEDCANCELLED。其中 QUEUED 表示 run 已经创建,但还没有真正进入 worker 执行;RUNNING 表示 worker 已认领并开始处理;COMPLETEDFAILEDCANCELLED 是比较直观的终态。真正体现智能体系统复杂性的是中间那几个额外状态:WAITING_FOR_USER 表示当前失败不是纯技术故障,而是需要用户先完成登录、过验证码、授权等动作;PARTIAL 表示任务没有彻底成功,但已经拿到一部分有价值的结果,因此不应该简单算作全量失败;BLOCKED 则更偏“当前执行被阻断,但还没有进入用户可恢复流程”的保留状态。很多 run 的问题并不是“全部失败”,而是“需要外部介入”或者“能部分交付”,所以相比普通 CRUD 任务,这种细分状态更适合智能体运行时的真实情况。创建 run 时,API 进程会直接把 Run.status 设成 QUEUED;worker 认领后,processClaimedRun() 会用条件更新把它从 QUEUED 推进到 RUNNING;全部计划执行完并且 finalizer 生成最终结论后,再从 RUNNING 推进到 COMPLETED。这里的状态推进都不是裸写,需要通过 transitionRunStatus(runId, expectedStatus, nextState) 这种方式做条件迁移,只有当前状态仍然符合预期时,迁移才会成功,例如只有 QUEUED 的 run 才能进入 RUNNING,只有 RUNNING 的 run 才能进入 COMPLETED,这样可以避免并发条件下的脏写和非法跳转。

若任务执行时出现异常,Tinymanus 会把错误归一化为结构化 AgentFailure 对象:

interface AgentFailure {
  // 失败码,如 DEPENDENCY_LLM_TIMEOUT、AUTH_LOGIN_REQUIRED
  code: string;
  // 失败发生的阶段,例如 PLANNING、STEP_EXECUTION、TOOL_INVOCATION、FINALIZATION
  stage: string;
  // 失败家族,如 DEPENDENCY、AUTH、EXECUTION、AUTOMATION_BLOCK
  family: string;
  // 可恢复性
  recoverability: string;
  scope: string;
  // 推荐动作
  action: string;
  message: string;
  retryable: boolean;
  // 附加数据
  metadata?: Record<string, unknown>;
  cause?: {
    name?: string;
    message?: string;
    stack?: string;
  };
}

根据异常的分类,系统有以下几种分支可以尝试:

  1. 可重试异常(RETRYABLE):调用 scheduleRunRetry(),把 Run 重置回 QUEUED、清空失败上下文,再释放当前队列 claim,让 worker 以后重试。比如 LLM 流式响应被中途断开、上游短暂不可用,这类错误通常走这条路。
  2. 需要用户动作的异常(USER_ACTION_REQUIRED):,系统会把 run 置为 WAITING_FOR_USER,并把 requiredUserActionblockedPlanItemIdfailureSummary 一起持久化。比如目标网站要求登录、验证码、权限授权,这时不是“系统坏了”,而是“用户先处理一下外部阻塞”。
  3. 部分完成异常(PARTIAL):系统可以保留已完成的结果和失败摘要。这适合“已经拿到部分结果,但后续步骤失败”的情况。
  4. 终态失败异常(FAILED):其余不可恢复错误调用 markRunAsFailed(),把 run 落成 FAILED,然后写错误 trace、事件和终态失败摘要。

对于 run 相关的异常,失败摘要还会落到 Run.failureSummary 持久化,必要时还会附带 requiredUserActionblockedPlanItemId,这方便了后续回看 agent 执行过程中遇到的问题。

运行状态同步、为什么又必须用 Redis 了

在用户在前端网页等候时,Tinymanus 会实时将 run 的状态同步到前端,用户可以在前端查看 run 的执行进度、工具调用情况、结果和失败摘要。考虑到这一需求的特点:服务端单向轻量级推送、天然融入鉴权机制,我们选择基于 HTTP streaming 的 SSE 推送 run 的实时消息。在 run 执行期间,服务端持续把状态变化、计划更新、工具调用进展和结果摘要推给前端,但前端并不需要在同一条长连接里高频反向发消息,像取消 run、恢复 run、重新创建任务这类动作,都完全可以通过普通 HTTP API 单独发起。在这种前提下,如果改用 WebSocket,连接生命周期、双向协议、消息路由和前端状态管理都会变得更加复杂。

Tinymanus 给前端暴露的实时接口是 GET /v1/runs/:runId/stream。这个接口本质上是一个标准的 SSE 长连接:客户端先带着登录态访问它,API 进程完成鉴权并确认当前用户确实有权访问这条 run,然后把 HTTP 响应切换成 text/event-stream。纯轮询也可以实现准实时获取消息的效果,但其体验和成本都不太理想。轮询的核心问题是:如果间隔拉得短,数据库和 API 会承受大量“其实没变化”的重复查询;如果间隔拉得长,用户看到的状态就会明显滞后。对于 run 这种会持续几十秒甚至更久的长任务,工具调用、计划推进和失败摘要又都带有明显的“过程感”,纯轮询会让界面要么显得迟钝,要么显得低效。也正因为如此,当前实现采用 SSE 实现实时获取消息的逻辑,同时保留要求后端从数据库读取真相信息的客户端 HTTP 接口。

第一个 MVP 版本没有实现“逐个 token 输出 LLM 的思考过程”,这其实是一个有意的克制。模型思考过程并不总是稳定协议的一部分。不同 provider 对 reasoning 字段的支持差异很大,有些模型根本不返回,有些会返回但要求在 continuation 时按特殊格式回传。把它直接作为前端协议暴露出去,会把 provider 差异一路传染到 UI 层。对目前阶段而言,逐 token 展示思考过程的产品价值没有看起来那么高。对很多用户来说,真正重要的是“任务现在做到哪一步了”“在调什么工具”“有没有遇到阻塞”“最终结论是什么”,而不是看到一长串高速刷新的内部推理 token。相比之下,计划项状态、工具调用记录、失败原因和结果摘要,其实更接近用户真正需要的可解释性。相比之下,reasoning 内容更为冗长、重复,因此我们将“模型思考”先消化在执行链路内部,对外呈现 RunPlanItemRunTraceEntryToolInvocationfailureSummary 等更稳定的产品化结构,控制住 MVP 阶段向用户展示的运行信息。

在这条 SSE 链路里,API 进程本身并不生产运行时消息,它只负责转发,真正的消息生产者是 worker。应用层在处理 run 的过程中会在关键节点调用 publishRunLiveEventSafely() 发布一条轻量级 RunLiveEvent,结构包括:

  • eventId
  • runId
  • sequenceNo
  • type
  • occurredAt
  • payload

Worker 进程在推进状态、创建或更新 plan item、输出 token 增量、记录工具调用阶段性结果等场景都可以发出对应的事件消息,而 API 进程获取到这些消息后,就直接把 event.type 写成 SSE 的 event name,再把整个事件对象作为 data 发给浏览器。这样前端就能在 run 执行时持续收到 status.changedplan_item.updatedtoken.deltatool.call.delta 这一类高频更新。

由于生产和分发事件等逻辑分散在两个不同进程里,我们需要一种低延迟的方式将事件从 worker 进程发送到 API 进程。SSE live event 本身没有强实时持久化的需求,但进程间消息传递又需要满足强实时、低延迟、高吞吐量的要求,因此我们选用 Redis Pub/Sub 广播总线来实现跨进程 live event 传递的过程。生产消息时,worker 先按上面的结构补全 RunLiveEvent 对象的结构,确保已经连接到 Redis client,然后执行 PUBLISH <channel> <json_message> 发送消息。对于目前的初版而言,将各个 run 的消息混合到同一共享 channel 中足以满足需求。

在客户端发起 SSE 连接时,API 进程的 SSE 路由会调用 runLiveEventSubscriber.subscribe({ runId }, handler) 订阅 Redis 总线并监听消息,其中 <runId, handler> 存在专门的 KV map 里,将 run 和对应的处理逻辑关联起来。在收到消息时,API 进程首先将 JSON 解析为 RunLiveEvent 对象,然后提取 runId 并调用对应的处理函数,将消息发给对应的客户端。这套机制可以分为三层:

  1. Redis 层:维护跨进程共享的消息总线
  2. API 进程内的 live event bus :维护本地订阅表,并按 runId 过滤出与指定 run 相关的事件
  3. SSE 路由层:把过滤后的事件写成 event: ...\ndata: ...\n\n 这样的 SSE frame 发给浏览器

当前实现还有两个比较细的工程点。第一,建立 SSE 连接并订阅消息时 API 进程会先把本地订阅注册进 subscriptions ,再去等待底层 Redis 订阅真正建立完成,这样可以减少流启动阶段丢帧的风险:如果 worker 恰好在 SSE 刚建立时就发布一条 live event,本地至少已经有地方可以接住后续消息。第二,close() 时会清理本地订阅,而如果整个 bus 关闭,还会对 Redis 执行 UNSUBSCRIBE 并关闭 publisher / subscriber 两个 client。也就是说,本层完成了对 Redis 连接生命周期的管理工作。

现在我们的目录结构如下:

tinymanus/
├── prisma/
└── src/
    ├── modules/
    ├── infrastructure/
    │   ├── logger/
    │   ├── data_access/
    │   ├── database/
    │   ├── security/
    │   ├── execution/
    │   └── live_events/  # 运行时事件总线维护
    ├── application/
    ├── interfaces/
    ├── app/api/
    └── bin/

前端:设计语言、AI 友好骨架(shadcn/ui+tailwind+react)

行百里者半九十。我们完成了符合版本预期的后端功能并进行了详尽的测试,现在是时候向前端部分发起冲锋了。前端和用户的交互体验紧密相关,我们需要考虑一些相对基础的问题:信息层级是否清楚、实时过程是否可理解、以及后续是否足够容易继续迭代。

Tinymanus 希望给用户带来一种冷静、清晰、可信、可持续阅读的体验。首先,智能体运行过程中会产生很多信息:状态、计划、工具调用、日志、结果、失败摘要、下一步动作。如果这些内容没有层次地堆在一起,用户会立刻感到压迫和混乱。因此界面应该优先突出当前最重要的信息,例如 run 的主状态、当前正在执行的步骤、是否需要用户介入、最终是否已经产出结果;其他技术细节则应该退到次级区域,必要时通过折叠、分组和排版节奏来控制密度。Tinymanus 不是一个点一下就结束的产品,用户被期望停留在 run 详情页看它执行、等待它完成,或者回头检查它为什么失败,所以界面不能过分依赖高饱和色块、密集边框和过强的动效刺激,而更适合采用一种偏安静的阅读型布局:留足白空间,文本宽度可控,状态强调有节制,重点区域突出但不刺眼,这样用户长时间观看也不会疲劳。Tinymanus 会展示工具调用、计划步骤、执行日志等带有明显工程属性的信息,因此界面里可以保留适度的技术感,对工具名、路径、状态标识使用更规整的排版和等宽字体,但这种技术感应该服务于理解,而不是变成装饰性的“黑客风”。用户需要感受到系统是有结构、有过程、可追踪的,而不是被一堆技术细节和术语推远。本版本希望为用户提供一种冷静、清晰、可信的数字工作台体验,它用明确的信息层级、克制的状态强调和适合长时间阅读的排版,把“智能体正在做什么、做到哪一步、出了问题怎么办”稳定地呈现给用户。

在信息架构上,Tinymanus 前端必须同时容纳几类完全不同的信息:一类是 agent 和 thread 这样的静态配置;一类是 run 的当前状态、计划列表、工具调用、失败摘要等动态过程信息;还有一类是认证、积分余额、历史记录这些控制面信息。如果把这些内容粗暴堆在同一个页面里,用户会非常容易迷失:既看不出“任务现在在做什么”,也看不出“出了问题之后下一步该怎么办”。因此在界面设计上,前端更适合采用一种面向执行过程的层级结构:先把当前 run 的主状态卡片放在最高优先级,再把计划步骤、工具调用、执行日志、结果摘要按“当前最重要 -> 需要时展开”的顺序组织起来。这样用户不需要理解后端所有数据表和状态机,也能抓住系统当前的主要意图。智能体系统的输出天然比传统 CRUD 更“长”、更“过程化”、更“不稳定”,它会出现长文本、Markdown、工具名、结构化 JSON、执行日志、步骤状态和失败原因等元素。前端如果仍然沿用传统后台那种“大表格 + 表单”的组织方式,用户会很快淹没在细节里。因此 Tinymanus 选择的设计方向更偏向“文档式 + 流程式”的混合界面:对结果和摘要使用更适合阅读的文本布局,对工具调用和技术标识符使用等宽字体,对噪声较高的部分采用折叠面板隐藏起来,对状态和下一步动作则做更明显的视觉聚焦。

Tinymanus 当前采用的是一套比较克制的现代 Web UI 组合:React + Tailwind CSS + shadcn/ui。这套组合对 AI 辅助开发较为友好,组件边界清晰,状态和视图靠得近,样式和结构共处一处,基础组件定制空间也更大。Tailwind 将组件描述的逻辑同样式定义结合在一起,避免在 MVP 阶段被大体量样式文件拖慢节奏,而 shadcn/ui 提供了一组可直接落进项目代码、便于二次修改的基础组件,这样的技术选型有利于在 AI 驱动开发时快速搭好骨架,同时也保留了一定程度的设计自由度。

前端相关目录结构如下:

apps/web/src/
├── app/                 # 应用入口、路由、全局 provider
├── features/            # 页面
│   ├── home/            # 首页
│   ├── auth/            # 登录、注册
│   ├── workspace/       # 登录后的工作区骨架与新建任务页
│   ├── agents/          # agent 管理
│   ├── runs/            # run 列表、run 详情、SSE 实时同步
│   └── credits/         # 积分余额与流水概览
├── state/               # 登录态、主题、侧边栏偏好等前端状态
├── lib/api/             # API client 与 DTO 类型
└── components/          # 布局、内容和图标等共享组件

前端的三层自动化测试骨架

后端作为提供 API 接口的程序,用 LLM 生成覆盖不同边界的测试脚本较为容易,但是前端为用户提供的内容就涉及到页面组件的设计、排放、外观、交互动线了,因此自动化测试前端应用要下更大的功夫。本仓库设计了三层自动化测试模型覆盖不同层级的用户使用场景,不仅能测试到局部的功能能否正常工作,也能对用户的视觉与交互体验作出评估。

第一层是基于 node:test 的确定性逻辑回归测试,与后端的测试套件恰巧相同。这一层主要关注局部函数等“不需要真实浏览器也能稳定验证”的前端逻辑,包括 API client 请求整形、错误信息归一化、表单校验、状态映射、摘要生成、query helper、storage 序列化等。仓库里这一层的测试脚本分布在各 feature 附近,例如:

  • src/features/runs/__tests__/
  • src/features/auth/__tests__/
  • src/features/credits/__tests__/
  • src/state/__tests__/
  • src/app/__tests__/

第二层是基于 Playwright 的浏览器回归测试。这一层解决的是“代码逻辑没问题,但真实用户操作时页面行为是否真的正确”,模拟用户在真实浏览器中的操作,验证页面能否按预期的交互流程工作。浏览器回归测试分为两类,一类是 mock-backed 的浏览器回归,验证页面结构和交互是否按预期工作,这类测试覆盖面更广,而另一种是 real-backed 的端到端回归,直接跑真实登录、创建 run 等真实后端流程,实现用户业务操作的真正闭环。两者组合起来,既能覆盖页面交互,也能保护和后端联动的关键路径。第一层和第二层测试可以像红绿灯一样清晰地说明程序能不能按预期实现主要功能,对于这些测试,仓库的逻辑必须全部通过。

vLLM 驱动测试

上面的 e2e 测试解决的是页面组件功能能否正常工作的问题,但无法覆盖样式、视觉体验、可访问性等方面,而这些内容对于前端应用来说又是非常重要的。本项目引入了 vLLM 驱动测试框架 Skyvern 来构成第三层自动化测试工具,利用 LLM 对前端的总体用户体验进行体验评审。

要进行 AI 驱动测试,首先要定义好测试任务。vLLM 读取到的一个测试任务是一个 JSON 对象,其属性定义如下:

  • reviewId:测试名称
  • userRole:用户角色,如:TinyManus user resuming a blocked or waiting run
  • journey:测试路径,定义了测试目标及若干条真实用户的操作步骤
  • focus:测试关注点,如:clarity of next-step guidance
  • successCriteria:对程序运行行为的期望
  • run:一些技术参数
  • auth:允许测试使用的 URL、用户名、密码等资料

LLM 对这样的结构化提示词可以进行一定程度的自由解读,但是对总的测试任务仍然应当有明确的理解。测试套件首先对这样的测试提示词进行 schema 校验,然后创建 Skyvern client、连接到 Skyvern 后端,将评审请求转换为 Skyvern workflow/run 并启动真实浏览器流程。测试套件隔一段时间会轮询执行状态直至 run 运行结束。

单次 Skyvern 结束后,LLM 会输出一个结构化测试报告,其发现的问题会归为错误、警告、提示三类,每条意见均由影响步骤、观察结论、证据摘要及建议构成,其中错误代表用户无法完成预期动作,警告代表虽然用户可以完成动作,但用户体验存在较为严重的缺陷,而提示代表 LLM 认为仍然存在并不紧急的提升空间。由于 AI 的结论存在不确定性,本层测试发现问题并不一定代表仓库代码一定存在问题,具体是否采纳这些意见仍需维护者决定。

这三层测试工具覆盖了不同层次的用户使用场景的测试需求,相比传统的前端测试方案能更为完善地描述产品功能的正常性及用户在使用产品时的体验。测试相关的目录结构如下:

apps/web/
├── src/
├── e2e/             # 覆盖不同功能的端到端测试
│   ├── auth/
│   ├── workspace/
│   ├── support/
│   ├── runs/
│   └── credits/
└── skyvern/         # Skyvern 测试
    ├── schemas/     # 测试任务 schema
    ├── prompts/     # 测试任务提示词
    ├── reviews/     # 测试报告
    ├── *.ts         # 测试套件逻辑
    └── *.test.ts    # 测试套件的测试套件

压力测试:看看它能不能满足 MVP 的需求

前端的三层测试以及简单的用户操作证明了当前版本的 Tinymanus 在功能上可以满足需求,现在我们来看一下它在技术上能表现出怎样的数据。我们使用 k6 先后测试 Tinymanus 在单实例环境下的接口承受能力和 worker 承载能力,测试脚本目录结构如下:

tinymanus/
├── prisma/
├── src/
├── apps/web/
└── scripts/load/
    ├── api/        # 测试脚本
    └── fixtures/   # 清理等辅助工具

控制面压测逻辑会持续访问 GET /v1/auth/meGET /v1/agentsGET /v1/runsGET /v1/credits/balance 等接口,我们期望 http_req_failed 控制在 1% 或 2% 以下,http_req_duration P95 控制在 500ms 或 1000ms 以内。K6 对 SSE 的测试能力相对有限,我们自行构建了 SSE 冒烟测试脚本测试 SSE 并发状况下的成功率和连接建立时间。控制面实际表现如下:

控制面压测表现:

档位 时长 http_req_failed http_req_duration p95 login_duration p95 结论
200 VU 30s 0.00% 312ms 436ms 通过阈值,健康
500 VU 30s 0.00% 2.22s 2.25s 明显超阈值,不健康
1000 VU 20s 0.00% 5.44s 5.47s 更严重超阈值,不健康

中位数延迟在 500-1000VU 场景下依然在 2ms 左右,说明大部分请求依然很快,只是尾部请求面临较大压力。观察表格可以看到 login_duration p95http_req_duration p95 几乎同步恶化,因此控制面瓶颈很可能集中在登录鉴权部分。将控制面测试分拆成“仅登录鉴权”和“登录后复用会话”两个部分,得到数据如下:

场景 500 VU 1000 VU
login-only http_req_duration p95 3.25s 6.33s
reused-session http_req_duration p95 178.72ms 341.93ms

这些数据说明当前控制面的瓶颈就在于高并发登录鉴权,而重复登录也是压测逻辑的一个设计缺陷。登录部分为了安全性,校验密码的速度是受到限制的,不能为了提高吞吐量而提升密码校验的效率。要提升登录部分的吞吐量,可以限制单账号/IP 的登录请求频率或将登录记录更新的操作分拆成异步逻辑。错峰 SSE 连接表现:

连接数 okCount failureCount readyCount heartbeatSatisfiedCount avg timeToReady p95 timeToReady 结论
200 200 0 200 200 8ms 11ms 健康
500 500 0 500 500 6ms 10ms 健康
1000 1000 0 1000 1000 8ms 11ms 健康

突发 SSE 连接表现:

连接数 okCount failureCount readyCount heartbeatSatisfiedCount avg timeToReady p95 timeToReady max timeToReady 结论
500 500 0 500 500 319ms 352ms 356ms 健康
1000 1000 0 1000 1000 509ms 575ms 587ms 仍健康

为了评估 worker 的承载能力,压测脚本模拟“创建 run 并轮询直至终态”的完整链路并记录两项关键指标 queueWaitSecondsterminalSeconds,其中前一个反映一个 run 在真正开始执行前的排队时间,后一个反映从 run 创建到进入终态的耗时。在单 worker 进程运行并串行消费任务的情况下,1VU 的排队时间约在 3.75s 左右,到了 2VU 就飙升到 15s 以上了,这是 worker 进程目前一次只能执行一个长任务决定的。Tinymanus 的多进程抢占式设计使得我们可以水平扩展 worker 进程的数量,这样吞吐量会有较为可观的进步。接下来进行多 worker 负载实验,假设采用 2 vCPU + 2GB RAM 的轻量应用服务器运行 API 及 worker 进程,我们在同一时间发起数个 run 任务,等待一段时间后查看 worker 消费任务的情况,数据如下:

worker 数 突发 run 数 20s 快照 RSS 总量 CPU 总量
2 6 2 RUNNING / 4 QUEUED 428MB 1.2%
4 10 4 RUNNING / 2 COMPLETED / 4 QUEUED 757MB 0.1%
8 18 8 RUNNING / 1 COMPLETED / 9 QUEUED 1489MB 0.3%
10 22 10 RUNNING / 1 COMPLETED / 11 QUEUED 1043MB steady sample 0.4% steady sample
12 26 12 RUNNING / 1 COMPLETED / 13 QUEUED 2166MB peak sample 4.3%

由于当前 worker 进程一次只处理一条 run,当单条 run 长时间等待 LLM 或外部工具响应时,进程大部分时间并没有被 CPU 打满,却仍然无法继续消费队列。单纯增加 worker 进程数量虽然可以提高并发能力,但会线性增加内存占用,并很快受到轻量服务器资源预算限制。因此后续更合理的演进方向是将 worker 从“单进程单 run”的串行模型,重构为“单进程多 run”的异步并发模型,每个 worker 进程内部维护有限并发池、同时处理多条 run,并为每条 run 独立维护 lease、取消信号、步骤、异常等状态。

反思:事实上 LLM 是“顽固”的

在 vibe coding 的时候我们总会不自觉地把决定一些需求的权力下放给 LLM,事后又经常得出 LLM 的输出不符合我们的要求的结论。这既反映了 LLM 的决策并不一定能满足开发者的需求,同时也说明了我们“懒于”去了解期望得到的产品的很多十分精细的细节。

在 Tinymanus 从零到一构建的时候,有一些很重要的点是没有注意到的,其中最重要的就是视觉体验。如果没有在产品之前运用专用工具详细设计产品的界面与外观细节,而是给 LLM 过于自由的空间去设计,那么最终得到的用户界面会有很多细节是不一定合乎预期的。事实上 Tinymanus 的的此次版本存在大量视觉上的瑕疵,而为了权衡开发精力的投入、提供一个“及格”的用户体验,我们也是做了很多取舍。

我和 AI 在技术细节处也有一些分歧。我希望初版就将 LLM 的思考、中间输出逐个 token 发送给前端并允许用户通过展开相关步骤来实时看到 LLM 的思考过程,而 AI 则认为逐个 token 输出既需要充分考虑各个 LLM 提供商对流式输出支持的差异,同时这一功能也并不是 MVP 版本的高优需求。在与 AI 协作的过程中,当面临这样的分歧时,我们应该做一个控制方方面面的“强人领导”,还是尊重 LLM 的意见甚至削减产品的功能,这件事情值得我们思考。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容