
1. 从一次“模型说退款了但钱没动”说起如果你刚开始接触 Agent 开发大概率会遇到这样一个场景你写好了提示词接上了模型用户说“我要退款”模型回复“已为您发起退款申请请耐心等待”。看起来一切正常但你去查订单系统发现根本没有退款记录。问题出在哪模型只是“说”了这句话它并没有真的调用你的退款函数。这就是 Agent 开发里最容易被忽略的认知门槛LLM API 是无状态的。每次/chat/completions请求都是独立的 HTTP 调用模型不记得上一轮说了什么也不知道你有哪些工具可用。它只对本次传入的messages数组做一次前向计算然后返回结果。模型侧没有状态所有状态都在你的工程代码里。由此推导出 Agent 四大基础要素的最小闭环想让它“有人设”你得每次都把提示词发一遍Prompt想让它“知道有哪些工具”你得每次都把工具定义发一遍Function call想让它“记得”你得把历史消息重新发一遍记忆而这三样东西加上本轮用户输入和工具定义共同构成了每次请求的上下文Context。这篇笔记面向刚接触 Agent 开发、想先跑通再深入的学习者。我会用 TaoToken 统一 Key 接入依次验证 Prompt 模板渲染、Function call 触发与回传、记忆读写、上下文拼接是否符合预期并记录每步的观测点和常见报错对照。你不需要先理解所有理论跟着步骤跑一遍看到日志里上下文条数从 2 变成 4 再变成 6很多概念自然就通了。2. TaoToken 前置准备统一 Key 与 API 通道在开始写代码之前先把接入层准备好。TaoToken 提供统一的 API 通道你只需要一个 Key 就能调用多种模型省去分别注册和管理多个平台账号的麻烦。对于学习 Agent 四要素来说这样可以把精力集中在 Prompt、Function call、记忆和上下文本身而不是被接入细节分散注意力。首先到官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册账号然后在控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建好 Key 之后先复制保存后面配置里要用。API 的基础地址是 https://taotoken.net/api 注意这个地址后面不加 UTM 参数直接用于代码里的baseUrl配置。如果你习惯用命令行工具做快速验证可以先用 curl 测一下 Key 是否可用。把下面的YOUR_API_KEY替换成你刚创建的 Keycurl https://taotoken.net/api/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer YOUR_API_KEY \ -d { model: gpt-4o-mini, messages: [ {role: user, content: 用一句话说明什么是无状态API} ] }如果返回里有choices[0].message.content说明 Key 和通道都正常。这一步的观测点是HTTP 状态码 200返回 JSON 里choices数组非空。如果返回 401说明 Key 不对或没带上Authorization头如果返回 404检查一下 URL 是不是写成了带 UTM 的版本API 调用地址就是https://taotoken.net/api这个前缀。接下来准备项目依赖。我用的是 Java LangChain4j 的组合因为它在记忆管理和工具调用上的抽象比较清晰适合观察四要素的运作过程。在pom.xml里加入以下依赖dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.35.0/version /dependency同时确认maven-compiler-plugin里开启了参数名保留这个后面 Function call 参数映射会用到plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/parameters /configuration /plugin如果你用的是 Python思路完全一样把openai库的base_url指向https://taotoken.net/api即可。后面的配置片段我会以 Java 为主但每个环节的观测点和验证方法跟语言无关。3. 可复制配置Prompt、Function call、记忆、上下文四件套这一节给出可以直接复制的配置片段把四个要素串起来。先看整体结构一个Assistant接口负责对话入口一个AssistantConfig负责组装模型、提示词、记忆和工具一个RefundTool负责真正的副作用执行一个ChatContextLogger负责把每轮发给模型的完整上下文打出来。先写提示词文件放在src/main/resources/prompt/agent.md。提示词要分段写清楚角色、任务、边界和工具调用要求# Role 你是一个电商售后助手负责处理用户的退款诉求。 # Task 第一步确认商品和问题描述。 第二步判断是否属于质量问题。 第三步调用工具发起退款并告知用户单号。 # Limit 只处理质量问题导致的退款。 非质量问题不得调用 createRefund。 回复控制在 3 句话以内。 # Tools createRefund仅在用户确认商品存在质量问题时调用。 参数 itemName 传商品名reason 传问题描述。注意这里显式写了“调用工具发起退款”。如果只写“我将为您发起退款”模型大概率只输出这句话术而不触发工具调用因为它认为输出这句话就已经完成任务了。这是 Prompt 和 Function call 配合的第一个关键点。接着写Assistant接口。注意这里不写SystemMessage注解提示词从文件加载public interface Assistant { TokenStream chat(MemoryId String sessionId, UserMessage String userMessage); }MemoryId标注的参数就是会话 ID框架会用它来隔离不同会话的记忆。然后是核心配置类Configuration public class AssistantConfig { Bean public Assistant assistant( Value(${taotoken.base-url}) String baseUrl, Value(${taotoken.api-key}) String apiKey, Value(${taotoken.model-name}) String modelName, Value(${assistant.prompt-path:prompt/agent.md}) String promptPath, Value(${assistant.memory-max-messages:20}) int maxMessages, Value(${assistant.log-context:true}) boolean logContext) { String systemPrompt loadPrompt(promptPath); StreamingChatModel model OpenAiStreamingChatModel.builder() .baseUrl(baseUrl) .apiKey(apiKey) .modelName(modelName) .listeners(logContext ? List.of(new ChatContextLogger()) : List.of()) .build(); return AiServices.builder(Assistant.class) .streamingChatModel(model) .systemMessageProvider(memoryId - systemPrompt) .chatMemoryProvider(memoryId - MessageWindowChatMemory.withMaxMessages(maxMessages)) .tools(new RefundTool()) .build(); } private String loadPrompt(String promptPath) { try (InputStream in new ClassPathResource(promptPath).getInputStream()) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new IllegalStateException(加载提示词文件失败 promptPath, e); } } }对应的application.yml配置taotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model-name: gpt-4o-mini assistant: prompt-path: prompt/agent.md memory-max-messages: 20 log-context: true这里base-url指向 TaoToken 的 API 地址api-key从环境变量读取避免硬编码。model-name可以换成你需要的模型 ID。三件套 Base URL、Key、Model ID 都在这里配齐了。工具类RefundTool负责真正的副作用public class RefundTool { private static final Logger log LoggerFactory.getLogger(RefundTool.class); Tool(为用户发起退款申请。仅在用户已确认商品存在严重质量问题时调用调用后款项按原路径退回) public String createRefund( P(商品名称或描述用户没说清楚时填未知商品) String itemName, P(质量问题的具体描述例如袖口开线) String reason) { String refundNo RF System.currentTimeMillis() ThreadLocalRandom.current().nextInt(100, 1000); log.info([模拟退款接口] 发起退款成功, refundNo{}, item{}, reason{}, refundNo, itemName, reason); return 退款申请已提交退款单号 refundNo 款项将于 1-7 个工作日内退回原支付账户; } }Tool的文字会变成工具描述方法名变成工具名P的文字变成参数描述。这些文字就是模型选工具和填参数的全部依据所以要写清楚。最后是上下文日志监听器这是观察四要素最重要的工具public class ChatContextLogger implements ChatModelListener { private static final Logger log LoggerFactory.getLogger(ChatContextLogger.class); Override public void onRequest(ChatModelRequestContext context) { ListChatMessage messages context.chatRequest().messages(); StringBuilder sb new StringBuilder(); sb.append(\n┌── 发给模型的完整上下文共 ) .append(messages.size()).append( 条); for (ChatMessage message : messages) { sb.append(\n│ ).append(render(message)); } sb.append(\n└──────────────────────────────); log.info(sb.toString()); } private String render(ChatMessage message) { if (message instanceof SystemMessage systemMessage) { String text systemMessage.text(); return [system] firstLine(text) …提示词全文 text.length() 字; } if (message instanceof UserMessage userMessage) { return [user] userMessage.singleText(); } if (message instanceof AiMessage aiMessage) { StringBuilder sb new StringBuilder([ai] ); if (aiMessage.text() ! null !aiMessage.text().isBlank()) { sb.append(aiMessage.text()); } if (aiMessage.hasToolExecutionRequests()) { for (ToolExecutionRequest request : aiMessage.toolExecutionRequests()) { sb.append(\n│ └ 调用工具 ).append(request.name()) .append( ).append(request.arguments()); } } return sb.toString(); } if (message instanceof ToolExecutionResultMessage resultMessage) { return [tool: resultMessage.toolName() ] resultMessage.text(); } return [ message.type() ] message; } private String firstLine(String text) { int idx text.indexOf(\n); return idx 0 ? text.substring(0, idx) : text; } }这套配置跑起来之后每轮请求都会在日志里打印出完整的消息列表。你可以清楚看到 system 提示词、历史消息、本轮输入、工具调用意图和工具返回值分别长什么样。4. 逐步验证从 Prompt 渲染到上下文拼接配置就绪后按顺序验证四个要素。每一步都有明确的观测点看到预期结果再进入下一步。4.1 验证 Prompt 模板渲染启动应用后先发一条最简单的消息curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {sessionId: test-001, message: 我买的衣服袖口开线了}观测日志里ChatContextLogger的输出。第一轮应该看到 2 条消息┌── 发给模型的完整上下文共 2 条 │ [system] # Role …提示词全文 320 字 │ [user] 我买的衣服袖口开线了 └──────────────────────────────如果 system 消息缺失检查systemMessageProvider是否配置正确以及提示词文件路径是否在 classpath 下。如果提示词内容为空loadPrompt会抛异常导致启动失败这是故意的避免带着空提示词跑。4.2 验证 Function call 触发与回传继续发第二轮确认商品问题curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {sessionId: test-001, message: 对的就是质量问题}这一轮日志会变得丰富。你会看到同一轮对话里出现了两次模型请求。第一次请求后模型返回了工具调用意图│ [ai] │ └ 调用工具 createRefund {itemName: 衣服, reason: 袖口开线}然后框架执行RefundTool日志里出现[模拟退款接口] 发起退款成功。接着第二次请求把工具返回值追加进去│ [tool:createRefund] 退款申请已提交退款单号 RF1234567890…最终模型基于工具返回值生成话术。这里的关键观测点是createRefund的日志只出现一次说明工具没有被重复调用工具返回值里的单号出现在最终回复里说明回传链路通了。如果模型只输出“已为您发起退款”但没有调用工具那行说明提示词里没有显式命令调用工具。回到提示词文件把“调用 createRefund 工具”写进去。4.3 验证记忆读写发第三轮消息测试模型是否记得之前的商品名curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {sessionId: test-001, message: 退款什么时候到账}观测日志里的消息条数。如果记忆正常工作这一轮应该看到 6 条或更多消息包含前两轮的 user 和 ai 消息。模型回复里应该能提到“衣服”或之前的退款单号说明它从历史消息里读到了信息。这里有个容易困惑的点模型并不“记得”商品名它只是每次请求都收到了包含商品名的历史消息。记忆的本质就是“把历史重新发一遍”。浏览器每次只发了一句话但后端从ChatMemory里取出历史拼进了请求。4.4 验证上下文拼接把三轮对话的日志连起来看消息条数应该是 2 → 4 → 6 递增。每一轮新增两条上一轮的 user 消息和 ai 回复。如果某轮工具被调用还会多出工具调用意图和工具返回值两条。你可以用ChatContextLogger的输出来核对system 提示词每轮都在且内容一致历史消息按时间顺序排列本轮用户输入在最后工具定义虽然日志里看不到但它每轮都作为请求的一部分发送。到这里四要素的最小闭环就跑通了。Prompt 通过systemMessageProvider注入到messages[0]Function call 通过Tool定义、模型决策、框架执行、结果回传完成一轮交互记忆通过MemoryId和MessageWindowChatMemory实现按会话隔离的历史管理上下文则是这三者加上本轮输入和工具定义的总和。5. 常见报错排查对照跑通的过程中难免遇到报错。这一节列出几个高频问题和排查方法对照真实报错定位。401 Unauthorized最常见的原因是 API Key 没配或配错。检查application.yml里的api-key是否读到了环境变量或者 curl 命令里的Authorization头是否带了Bearer前缀。如果 Key 是从控制台复制的注意不要带多余空格。TaoToken 的 Key 在控制台 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 管理可以重新生成一个再试。local proxy failed / connection refused这类报错通常出现在本地网络配置有问题时。检查base-url是否写成了https://taotoken.net/api不要多加路径或参数。如果你在公司网络环境下确认没有额外的网络策略拦截。这个报错跟 Key 无关是连接层的问题。reading choices 时返回空数组说明请求发出去了但模型没有返回有效内容。检查model-name是否是 TaoToken 支持的模型 ID。如果模型名写错有些通道会返回空choices而不是明确报错。另外确认messages数组非空且第一条消息的role是system或user。OAuth 相关报错如果你用的是某些需要 OAuth 认证的工具链可能会看到 token 过期或 scope 不足的提示。TaoToken 的 API Key 方式是 Bearer Token不涉及 OAuth 流程。如果工具链强制走 OAuth检查它的配置是否指向了正确的认证端点。工具参数变成 arg0 / arg1这是 Java 编译没保留参数名导致的。模型只能靠P描述猜顺序参数一多就容易错位。解决办法是在maven-compiler-plugin里加parameterstrue/parameters然后重新编译。验证方法是看日志里工具调用的arguments如果显示的是{itemName: 衣服}而不是{arg0: 衣服}说明参数名保留成功了。模型不调用工具只输出话术回到提示词检查。凡是期望模型产生副作用的场景提示词里要同时写清楚三件事什么时候调触发条件、什么时候不能调负向边界、参数怎么填映射关系。只写话术不写调用指令模型会认为输出话术就完成了任务。记忆没有累加每轮都是 2 条消息检查chatMemoryProvider是否配置了以及MemoryId标注的参数是否在每次调用时传了相同的值。如果sessionId每次都是新的框架会为每个新 ID 创建独立记忆看起来就像没有记忆。另外确认MessageWindowChatMemory.withMaxMessages的数值不是 0。上下文条数对不上工具返回值没进记忆确认你用的是框架的ChatMemory而不是自己拼messages。LangChain4j 的AiServices会自动把工具调用记录和返回值追加回记忆。如果你在裸调 HTTP API需要自己维护tool消息并塞回下一轮请求。6. 继续深入的方向跑通四要素最小闭环之后你可以沿着几个方向继续深入。Prompt 方面尝试把提示词拆成角色、任务、边界、工具四段给正例也给反例观察模型输出稳定性的变化。Function call 方面试着增加第二个工具比如发优惠券然后观察模型在多个工具之间如何选择以及描述文字怎么写才能减少误调。记忆方面把MessageWindowChatMemory换成带持久化的实现或者试试摘要压缩策略对比长对话下的表现。上下文方面用ChatContextLogger统计每轮的 token 用量感受一下为什么对话越长越贵。如果你想把这条链路用到实际编码或 Agent 场景里可以了解一下 Coding Plan它提供了更适合长期编码任务的配置方案https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。需要快速验证模型对话效果的话模型对话入口在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。接入过程中遇到配置问题接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 有更详细的参数说明。最后留一个实用技巧每次改完提示词或工具描述不要靠阅读判断效果攒一个小的用例集跑一遍回归。比如“物流致损”这种边界场景就是很好的负样本。模型选错工具是必然会发生的设计目标不是写出完美描述而是降低误调概率同时用代码做服务端校验兜底。模型负责归因分类代码负责策略决策这个分工想清楚了Agent 的稳定性会上一个台阶。