从零开始编写自己的AI账单Agent

AI时代,很难想象记账App还没有用AI帮自己分析账单的功能

起因是有小伙伴问,能不能给Cent加上AI记账分析的功能,一开始我觉得这当然没问题了,实在是太简单了,不就是加一个聊天框,然后预设一些提示词发给AI,然后就成了嘛,于是我直接开写。

当我把整体流程吭哧吭哧写完了,点击发送按钮一看,AI返回了一个大大的问号:

好的,请提供您的账单数据,我将会尽力为你进行分析

我才想起忘了把账单数据也一起发过去,这好办,我直接在systemPrompt里开写:

下面是用户的账单数据:

这时我马上意识到了一个问题,那就是如果真的把账单数据一股脑传给AI,假设用户一天产生5条账单,一个月就是150条,一年就是1000+条数据,这么多数据传过去,先不说token消耗数直接爆炸,Transformer注意力机制下,这么多数据AI能否分析成功也是个未知数,保不齐就开始胡诌一个平均支出,或者把开始的数据忘得一干二净了。

直接把账单数据传给AI显然是不合理的,于是我想到了目前最热门的Curosr、Manus等AI工具,它们是如何解决上下文问题的呢?于是我开始问Gemini,找到了这样一个关键词:ReAct

ReAct

此ReAct当然不是前端用的彼React,而是一种全新的AI上下文处理架构,Gemini解释如下:

简单来说,ReActReason(推理) + Act(操作) 的缩写。

它是大模型(LLM)的一种工作模式,让模型在执行复杂任务时,不仅会"想",还会"做"。

核心原理

ReAct 模仿人类解决问题的思维过程,通过一个循环的反馈链条来实现:

  1. Thought(思考): 模型分析当前任务,思考下一步该做什么。
  2. Action(行动): 模型调用外部工具(如搜索网页、查询数据库、计算器)。
  3. Observation(观察): 模型阅读工具返回的结果,并据此修正或继续下一步推理。

为什么需要它

  • 弥补缺陷: LLM 有时会"一本正经胡说八道",且无法获取实时信息。
  • 逻辑闭环: 通过"观察"外部事实,模型能自我纠错,从而更精准地达成目标。

一句话总结: ReAct 是给大模型装上了"大脑(逻辑)"和"双手(工具)",让它通过边想边做来解决问题。

原理很简单,现在各家AI厂商都在用的MCP、Skills模式,其实都是为了更好地让大模型遵循一定的步骤,来依分步完成复杂任务,而为了实现ReAct,将不确定的大模型回答转换为确定的数据收集工作,就需要让大模型能够调用确定的"功能",将这些"功能"的输出提供给大模型,而不是一股脑把所有数据丢给它,这样就能最大化减少token的消耗,以及避免幻觉,同时也能带来更好的执行结果。

在Cent中,账单数据是十分确定的,统计和分析功能也是现成的,剩下的问题就是该如何让大模型了解和使用这些现成的工具了。那么该如何实现简单的ReAct模式呢?

Function Call

Gemini给的方式非常简单,让大模型遵循function call格式的输出,然后在自己的解析代码中实现调用就可以了。Function call是Open AI很早前提出的一套用于规范大模型输出的提示词工程,旨在让大模型返回标准化的json结构,然后通过解析json来获取确定格式的输出,方便调用标准化的函数,其工作原理也很简单,就是先为自己的工具函数定义一套标准的JSON描述,例如函数名称,函数参数,每个参数的作用,返回值的定义等,相当于给函数一个大模型看得懂的注释,然后再要求大模型在需要调用工具获取信息的时候,按照给定的json格式输出,这样通过解析大模型的输出,就可以知道大模型要如何调用这些函数,再将函数执行结果重新返回给大模型,就实现了大模型"调用"工具的结果。

Gemini很快就给出了一套完善的function call的提示词,以及对应的解析方式,看起来很完美,但是当我把这套提示词发送给大模型时,又出岔子了,大模型根本就没按我给的格式来,而是自顾自地把Open AI那一套python格式的function call调用方式返回了,理所当然解析失败。

提示词改进

我用的大模型是智谱GLM flash系列,它的"智力"显然比不上目前的闭源大模型,目前的提示词约束力显然不太够,而且很明显它被训练过程的Open AI版本的function call协议污染了,一直按照Open AI的格式来,而对我的"简化版"协议置若罔闻。我只能完全放弃提到function call,重新设计一套更简洁的"协议",即最终版:

markdown 复制代码
# 强制输出格式
所有回答必须包含在对应的 XML 标签中,格式如下:

<TITLE>此处为简短标题</TITLE>
<Thought>
此处记录你的思考过程。
1. 分析用户意图。
2. 决策:是宏观统计(analyze)还是微观查询(query)?
3. 确定所需的参数。
</Thought>

<Tool>
function=工具名称
参数名1=参数值1
参数名2=参数值2
</Tool>
<Answer>
最终的输出
</Answer>

事实证明,xml版的协议对于小体量模型来说,约束力更强,也更不容易生成出错,并且解析代码也可以更加简洁,也更宽容,之前生成json的时候很容易出现匹配时多匹配了个引号或者大括号导致解析失败,换成xml之后只用正则就可以精准匹配想要的内容,而且拓展性也更强了。

typescript 复制代码
/**
 * 健壮的 XML 标签解析器 (Loose Parsing)
 * 即使缺少闭合标签或格式稍有偏差也能尝试提取
 */
/**
 * 增强后的解析器:支持 Answer 标签的提取与清洗
 */
function parseStandardResponse(response: string): ParsedResponse {
    const extractTag = (tag: string, input: string) => {
        // 匹配 <Tag>内容</Tag> 或 <Tag>内容 (支持未闭合情况)
        const regex = new RegExp(`<${tag}>([\s\S]*?)(?:<\/${tag}>|$)`, "i");
        const match = input.match(regex);
        return match ? match[1].trim() : undefined;
    };

    const title = extractTag("TITLE", response);
    const thought = extractTag("Thought", response);
    const toolRaw = extractTag("Tool", response);
    const answerTagContent = extractTag("Answer", response); // 新增:提取 Answer 标签内容

    let toolCall = null;
    if (toolRaw) {
        toolCall = parseToolContent(toolRaw);
    }

    // 清洗逻辑:移除所有 XML 块,包括 Answer
    const cleanedRemainder = response
        .replace(/<TITLE>[\s\S]*?(?:</TITLE>|$)/gi, "")
        .replace(/<Thought>[\s\S]*?(?:</Thought>|$)/gi, "")
        .replace(/<Tool>[\s\S]*?(?:</Tool>|$)/gi, "")
        .replace(/<Answer>[\s\S]*?(?:</Answer>|$)/gi, "") // 新增:移除 Answer 标记及其内部内容
        .trim();

    // 最终显示内容的优先级:
    // 1. 如果有 <Answer> 标签,优先使用标签内的内容
    // 2. 如果没有 <Answer> 标签,则使用移除所有标签后的剩余文本
    const content = answerTagContent || cleanedRemainder;

    return {
        title,
        thought,
        toolCall,
        content,
        raw: response,
    };
}

xml版"function call"解析器

函数优化

解决了大模型如何调用工具的问题,接下来就是如何设计工具了,对于记账App来说,即使过滤出了一段时间的账单,数量也可能还是太多了,直接返回过滤后的结果账单也没有比一开始的实现好上多少。因此,设计一个具有统计功能的工具是必要的,它应该返回全局性的统计数据,而非单纯的账单过滤,只有当用户明确需要查询具体账单时,才让大模型去调用对应的搜索工具。为此,我让Gemini设计了如下几个函数:

markdown 复制代码
# 可用工具

## 1. analyze_bills - 账单统计与分析 (优先使用)
**强烈建议**:当用户询问"总额"、"占比"、"哪类花钱最多"、"趋势"、"概况"时,必须使用此工具。它能返回高密度的统计结果,避免数据过载。
- **参数**:
  - function: "analyze_bills"
  - startTime: YYYY-MM-DD
  - endTime: YYYY-MM-DD
  - categoryNames: 分类名(逗号分隔,支持模糊匹配)
  - tagNames: 标签名(逗号分隔)
  - keyword: 备注关键词
  - minAmount / maxAmount: 金额范围(数字)
  - billType: "income" 或 "expense"
  - groupBy: 分组维度,可选值:
    - "category": 按分类统计(默认,适合看消费构成)
    - "tag": 按标签统计(适合看特定事件/项目)
    - "day": 按日统计(适合看每日变化)
    - "month": 按月统计(适合看月度趋势)
  - limit: 返回前几项(数字,默认10)
  - includeTrend: "true" 或 "false" (是否包含时间趋势数据,用于分析波动)

## 2. query_bills - 查询原始账单明细
**仅在以下情况使用**:用户明确询问"具体的某一笔交易"、"搜索特定备注"或"列出最近几笔账单"时。不要用于宏观统计。
- **参数**:
  - function: "query_bills"
  - xxx

## 3. get_account_meta - 获取账本信息
用于获取当前账本定义的分类结构和标签列表。
- **参数**:
  - function: "get_account_meta"

再通过提示词引导大模型在合适的场景调用合适的函数,最终传递给大模型的token数量得到了大幅缩减,不仅守护了用户的钱包,也获得了更高的回答质量。

至此,基于智谱GLM-Flash的,能够动态进行账单分析的ReAct模式版AI助手功能正式上线Cent,我尝试了预设提示词,每个提示词都能很好地完成从函数调用到数据分析的完整链路,即使是"生成年度总结"这种需要多步推理和函数调用的问题,模型也能很好地一步步进行执行和汇总,效果让我十分满意,只是受限于模型本身的参数,生成的回答深度比起其他闭源模型还是差了点意思,不过智谱的API是我暂时能找到的唯一免费,且支持跨域的API了,还要啥自行车呢。

抛开最终效果不谈,探索Agent模式本身也很有意义,通过提示词工程,让大模型一步步按照自己的想法执行,就像培养一个听话但不太灵光的孩子,有种"模拟养成"的快乐。

相关推荐
mldong6 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排7 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
linqiw7 小时前
Embabel 上手:Java Agent 是怎么干活的
agent
沧沧凉凉7 小时前
同一个 Blender 建模,Claude 两个模型都翻车,GPT-6 一次过
人工智能·游戏·ai编程
passerby60617 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角8 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!8 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰9 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
大模型真好玩9 小时前
DeepSeek Harness 入门很简单(三)——DeepSeek Harness接入工具、MCP、Skill
人工智能·agent·deepseek