1. 这不是报错,是系统在“敲门”:A2A 协议中 INPUT_REQUIRED 的真实含义与 taskId 续跑机制的本质
你正在调试一个跨服务调用链路,前端发来请求,后端服务 A 处理到一半,突然返回一个看似刺眼的INPUT_REQUIRED状态码,附带一个taskId: "a1b2c3d4-5678-90ef-ghij-klmnopqrstuv"。日志里没有堆栈,没有异常,只有这行 JSON。你第一反应是“接口挂了”,立刻去查服务 B 的健康状态、线程池、数据库连接——结果全绿。半小时后,你发现服务 B 根本没收到任何请求。问题不在下游,而在上游的“暂停键”被按下了。
这就是 A2A(Agent-to-Agent)协议中INPUT_REQUIRED的典型现场。它根本不是错误,而是一种显式、可控、可追溯的流程中断信号,其背后是一套精密的“任务续跑”(Task Resumption)机制。taskId不是流水号,而是这个中断点的唯一坐标;INPUT_REQUIRED不是失败,而是系统在说:“我需要外部输入,但请放心,我的上下文、状态、执行指针都已完整封存,等你带着答案回来,我立刻从断点继续。”
这个机制在当前多 Agent 协同、人机混合决策、长周期任务编排的场景中,已从“可选能力”变成“基础设施级刚需”。比如一个智能客服 Agent 在处理用户投诉时,识别出需人工审核赔偿方案,它不会直接返回“请稍候”,而是触发INPUT_REQUIRED,将当前会话快照、用户历史、初步分析结论全部打包进taskId关联的存储中;运营人员在后台看到待办任务,填入审批意见后,系统自动唤醒该taskId对应的执行上下文,让 Agent 继续生成最终回复并发送。整个过程对用户透明,对开发者可审计,对系统可扩展。
核心关键词A2A、INPUT_REQUIRED、taskId构成了这套机制的铁三角:A2A定义了通信范式(非传统 REST/HTTP,而是面向 Agent 能力的语义化交互);INPUT_REQUIRED是协议层定义的状态码,承载着“中断+等待”的语义;taskId则是状态持久化的锚点,是续跑的唯一钥匙。理解这三者的关系,是驾驭现代 Agent 协同系统的起点。它解决的不是“接口通不通”的问题,而是“任务能不能跨时间、跨角色、跨系统可靠推进”的问题。无论你是用 Spring Boot 写 Java Agent,还是用 C++ 实现高性能推理引擎,抑或只是想把现有服务包装成标准 A2A Agent 并接入统一 AgentBoard,绕不开的,就是如何正确响应INPUT_REQUIRED并安全地管理taskId生命周期。
2. 为什么必须用 INPUT_REQUIRED?深度拆解 A2A 协议设计背后的工程哲学
要真正吃透INPUT_REQUIRED,不能只看它返回什么,得先问:为什么 A2A 协议要专门设计这样一个状态码?它和传统的 HTTP 400、422、503 有什么本质区别?答案藏在三个层面的工程权衡里。
2.1 语义精确性:告别模糊的“重试”与“失败”
传统 Web API 面对缺失参数,常用 400 Bad Request 或 422 Unprocessable Entity。但这两个状态码的问题在于语义过载且不可操作。400 可能是 JSON 格式错误、字段类型不符、必填项为空,甚至网络传输损坏;422 更是笼统到“服务器理解请求实体的内容,但无法处理”。客户端收到它们,唯一能做的就是解析错误体里的message字段,然后靠字符串匹配去猜——“请输入手机号”、“邮箱格式不正确”、“订单ID不存在”。这种基于文本的解析脆弱、易错、难以国际化,更无法支撑自动化流程。
INPUT_REQUIRED则完全不同。它是一个协议级、机器可读、意图明确的信号。当 Agent A 向 Agent B 发起一个processComplaint请求,B 在校验环节发现compensationAmount字段为空,它不会返回含糊的 400,而是直接返回:
{ "status": "INPUT_REQUIRED", "taskId": "a1b2c3d4-5678-90ef-ghij-klmnopqrstuv", "requiredInputs": [ { "name": "compensationAmount", "type": "number", "description": "赔偿金额,单位:分" }, { "name": "approvalReason", "type": "string", "description": "审批理由,需说明依据" } ] }这里的关键是requiredInputs数组。它不是给人看的提示,而是给另一个 Agent 或前端 UI 框架消费的 Schema。UI 框架拿到这个数组,能自动生成表单字段(数字输入框 + 文本域),并绑定校验规则;另一个 Agent 收到后,能直接发起fetchInputForTask查询,精准获取所需数据源。这种从“错误描述”到“输入契约”的跃迁,是 A2A 协议对传统 REST 的降维打击。
2.2 状态可追溯性:taskId 是任务世界的“时空坐标”
taskId的价值,在于它把一次逻辑上连续的任务,物理上拆解为多个可独立调度、可异步执行、可跨节点恢复的片段。它的设计不是简单的 UUID,而是融合了时间戳、服务标识、序列号的复合结构。以a1b2c3d4-5678-90ef-ghij-klmnopqrstuv为例,其内部结构可能是:
a1b2c3d4: 生成该任务的 Agent 实例 ID(如complaint-processor-v2.1的哈希)5678: 任务创建的毫秒级时间戳(截取后4位,用于粗略排序)90ef: 该 Agent 当前处理的第0x90ef个任务(避免纯时间戳冲突)ghij-klmnopqrstuv: 随机熵值,确保全局唯一
这个结构带来的好处是可诊断、可归因、可治理。当一个taskId的续跑耗时超过阈值,运维平台能立刻定位到是哪个版本的 Agent、在哪个时间段、处理哪类任务时出现了瓶颈;当用户投诉“我的投诉卡住了”,客服只需输入taskId,就能在 AgentBoard 上看到完整的执行轨迹:[2024-05-20T14:22:01] Agent A 开始处理 -> [2024-05-20T14:22:03] 触发 INPUT_REQUIRED -> [2024-05-20T14:25:17] 人工输入完成 -> [2024-05-20T14:25:18] Agent B 继续执行。这种粒度的可观测性,是任何基于重试或轮询的方案都无法提供的。
2.3 架构解耦性:让“等待”成为一等公民
最根本的哲学转变,在于 A2A 协议将“等待外部输入”这一行为,从一种需要规避的副作用,提升为一种正交的、可组合的、可编排的核心能力。在传统微服务架构中,“等待”往往意味着:
- 阻塞线程:同步调用,线程挂起,资源浪费;
- 轮询污染:客户端定时 GET
/task/{id}/status,增加无效流量和延迟; - 状态机爆炸:为每个可能的等待点定义新状态(
WAITING_FOR_APPROVAL,WAITING_FOR_PAYMENT...),状态迁移逻辑复杂难维护。
INPUT_REQUIRED+taskId机制则彻底解耦。Agent B 在需要输入时,只需:
- 将当前所有上下文(内存中的对象、临时文件路径、数据库事务 ID)序列化,存入共享存储(如 Redis Hash 或专用 Task DB);
- 返回
INPUT_REQUIRED响应,附带taskId; - 主动释放所有资源(关闭数据库连接、释放内存、退出线程)。
此时,Agent B 已“死亡”,但任务未“终结”。后续的输入提交、状态查询、续跑触发,全部由独立的 Task Orchestrator 组件负责。这个组件可以是轻量级的事件监听器(监听 Kafka 主题task.input.submitted),也可以是复杂的 BPMN 引擎。Agent B 只需在启动时注册一个resumeTask回调函数,当 Orchestrator 收到输入并决定续跑时,它会调用该回调,并传入taskId和输入数据。Agent B 的代码里,不再有while(!inputReady) sleep(1000),只有清晰的onResume(taskId, inputs)函数入口。这种设计,让 Agent 的核心逻辑极度纯粹,也极大提升了系统的弹性与可伸缩性。
3. 实操核心:从零构建一个支持 INPUT_REQUIRED 与 taskId 续跑的 A2A Agent(以 Spring Boot 为例)
现在,我们把理论落地。假设你要用 Spring Boot 快速构建一个符合 A2A 协议的ComplaintProcessorAgent,它能处理用户投诉,并在需要赔偿审批时返回INPUT_REQUIRED,等待人工输入后继续执行。以下是经过生产环境验证的实操步骤,每一步都包含原理、代码、配置和避坑点。
3.1 协议层基础:定义 A2A 标准响应结构与状态码
首先,建立协议契约。在src/main/java/com/example/a2a/protocol/下创建核心类:
// A2AResponse.java - 所有 A2A 响应的基类 public class A2AResponse<T> { private String status; // "SUCCESS", "INPUT_REQUIRED", "ERROR", "TIMEOUT" private String taskId; private T data; // 成功时的业务数据 private List<InputRequirement> requiredInputs; // 仅 INPUT_REQUIRED 时存在 private String errorMessage; // 仅 ERROR 时存在 // getters & setters... } // InputRequirement.java - 描述所需输入的元数据 public class InputRequirement { private String name; // 字段名,用于数据绑定 private String type; // "string", "number", "boolean", "object" private String description; // 人类可读描述 private boolean required; // 是否必填 private Object defaultValue; // 默认值,可选 // getters & setters... }关键原理与避坑点:
status字段必须是枚举类型(A2AStatus),而非字符串。这是为了防止拼写错误(如"INPUT_REQURIED")导致客户端解析失败。Spring Boot 的@JsonCreator可轻松实现 JSON 字符串到枚举的反序列化。requiredInputs字段在status != INPUT_REQUIRED时必须为null,而不是空列表。这是为了强制客户端进行状态判断,避免误将空列表当作“无需输入”。taskId字段在status == SUCCESS时也应存在,用于追踪完整链路。很多团队只在INPUT_REQUIRED时返回taskId,这会导致成功任务无法被审计。
3.2 任务上下文管理:用 Redis 实现轻量级、高可用的 Task Store
taskId的生命线在于其关联的上下文存储。我们选择 Redis,因为它具备原子性、低延迟、天然支持 TTL(过期时间)三大优势。创建TaskStoreService:
@Service public class TaskStoreService { private static final String TASK_HASH_PREFIX = "a2a:task:"; private static final int DEFAULT_TTL_SECONDS = 3600; // 1小时 @Autowired private RedisTemplate<String, Object> redisTemplate; // 存储任务上下文,key 为 taskId public void storeTaskContext(String taskId, Object context) { String key = TASK_HASH_PREFIX + taskId; // 使用 Hash 结构,field="context",value=序列化后的上下文 redisTemplate.opsForHash().put(key, "context", context); redisTemplate.expire(key, Duration.ofSeconds(DEFAULT_TTL_SECONDS)); } // 获取任务上下文 public <T> T getTaskContext(String taskId, Class<T> clazz) { String key = TASK_HASH_PREFIX + taskId; Object context = redisTemplate.opsForHash().get(key, "context"); if (context == null) { throw new TaskNotFoundException("Task not found or expired: " + taskId); } return clazz.cast(context); } // 删除任务上下文(续跑完成后调用) public void deleteTaskContext(String taskId) { String key = TASK_HASH_PREFIX + taskId; redisTemplate.delete(key); } }关键原理与避坑点:
- 为什么用 Hash 而不是 String?因为一个
taskId可能关联多个数据块:context(主上下文)、metadata(创建时间、来源 Agent)、history(执行日志)。用 Hash 可以原子性地更新单个字段,避免并发写入冲突。 - TTL 设置的艺术:
DEFAULT_TTL_SECONDS不能设得太短(如 5 分钟),否则人工审批还没开始,任务就过期了;也不能太长(如 7 天),否则僵尸任务堆积。最佳实践是根据业务 SLA 设定,例如“人工审批平均耗时 15 分钟”,则 TTL 设为15 * 60 * 3 = 2700秒(3 倍平均值),并配合监控告警。 - 序列化陷阱:
Object类型的context在存入 Redis 前,必须被序列化。Spring Boot 默认使用 JDK 序列化,但性能差且不兼容跨语言。强烈建议切换为 Jackson 的GenericJackson2JsonRedisSerializer,并在context对象上添加@JsonInclude(JsonInclude.Include.NON_NULL)注解,避免存储大量null字段。
3.3 核心业务逻辑:在 ComplaintProcessorAgent 中植入 INPUT_REQUIRED 流程
现在,编写真正的业务逻辑。ComplaintProcessorAgent的核心方法processComplaint如下:
@Service public class ComplaintProcessorAgent { @Autowired private TaskStoreService taskStoreService; @Autowired private ApprovalService approvalService; // 人工审批服务,提供查询接口 // A2A 协议入口点 public A2AResponse<ComplaintResult> processComplaint( @RequestBody ComplaintRequest request) { // Step 1: 生成唯一 taskId String taskId = generateTaskId(); try { // Step 2: 执行前置校验与部分处理 ComplaintContext context = new ComplaintContext(); context.setComplaintId(request.getComplaintId()); context.setUserId(request.getUserId()); context.setInitialAnalysis(analyzeComplaint(request)); // 生成初步分析 // Step 3: 判断是否需要人工输入 if (needsManualApproval(context.getInitialAnalysis())) { // 构建 requiredInputs 列表 List<InputRequirement> requirements = Arrays.asList( new InputRequirement("compensationAmount", "number", "赔偿金额(分)", true, null), new InputRequirement("approvalReason", "string", "审批理由", true, null) ); // 将上下文存入 Task Store taskStoreService.storeTaskContext(taskId, context); // 返回 INPUT_REQUIRED 响应 return A2AResponse.<ComplaintResult>builder() .status(A2AStatus.INPUT_REQUIRED.name()) .taskId(taskId) .requiredInputs(requirements) .build(); } // Step 4: 无需输入,直接完成 ComplaintResult result = finalizeComplaint(context); return A2AResponse.<ComplaintResult>builder() .status(A2AStatus.SUCCESS.name()) .taskId(taskId) .data(result) .build(); } catch (Exception e) { // 记录错误日志,清理可能残留的上下文 log.error("Error processing complaint for taskId: {}", taskId, e); taskStoreService.deleteTaskContext(taskId); // 清理脏数据 return A2AResponse.<ComplaintResult>builder() .status(A2AStatus.ERROR.name()) .taskId(taskId) .errorMessage(e.getMessage()) .build(); } } // 续跑入口点:当人工输入提交后,由 Orchestrator 调用 public A2AResponse<ComplaintResult> resumeTask( @RequestBody ResumeTaskRequest request) { String taskId = request.getTaskId(); Map<String, Object> inputs = request.getInputs(); // {"compensationAmount": 5000, "approvalReason": "符合政策"} try { // Step 1: 从 Task Store 加载原始上下文 ComplaintContext context = taskStoreService.getTaskContext(taskId, ComplaintContext.class); // Step 2: 将输入数据注入上下文 context.setCompensationAmount((Integer) inputs.get("compensationAmount")); context.setApprovalReason((String) inputs.get("approvalReason")); // Step 3: 执行剩余逻辑 ComplaintResult result = finalizeComplaint(context); // Step 4: 清理上下文 taskStoreService.deleteTaskContext(taskId); return A2AResponse.<ComplaintResult>builder() .status(A2AStatus.SUCCESS.name()) .taskId(taskId) .data(result) .build(); } catch (TaskNotFoundException e) { return A2AResponse.<ComplaintResult>builder() .status(A2AStatus.ERROR.name()) .taskId(taskId) .errorMessage("Task context not found or expired") .build(); } catch (Exception e) { log.error("Error resuming task: {}", taskId, e); return A2AResponse.<ComplaintResult>builder() .status(A2AStatus.ERROR.name()) .taskId(taskId) .errorMessage(e.getMessage()) .build(); } } // 辅助方法:生成 taskId(示例,生产环境请用更健壮的实现) private String generateTaskId() { return UUID.randomUUID().toString(); } private boolean needsManualApproval(InitialAnalysis analysis) { // 业务规则:赔偿金额 > 1000 元(100000 分)需人工审批 return analysis.getEstimatedCompensation() > 100000; } private InitialAnalysis analyzeComplaint(ComplaintRequest request) { // 模拟 AI 分析逻辑 return new InitialAnalysis(request.getComplaintId(), 85000); } private ComplaintResult finalizeComplaint(ComplaintContext context) { // 模拟最终处理:生成工单、通知用户、更新状态 return new ComplaintResult(context.getComplaintId(), "PROCESSED", "已处理完毕"); } }关键原理与避坑点:
generateTaskId()的陷阱:示例中用了UUID.randomUUID(),这在单机环境下足够。但在分布式集群中,如果多个实例同时生成taskId,理论上存在极小概率重复(虽然 UUID v4 的碰撞概率是 10^-37)。生产环境推荐使用 Snowflake 算法,或直接依赖数据库的自增 ID + 时间戳组合,确保全局单调递增。resumeTask的幂等性:resumeTask接口必须是幂等的。即同一个taskId被多次调用,结果必须一致。我们的实现通过taskStoreService.deleteTaskContext(taskId)在续跑成功后立即删除上下文,保证了幂等性——第二次调用会直接抛出TaskNotFoundException,返回明确的错误。- 错误处理的黄金法则:在
processComplaint的catch块中,必须调用taskStoreService.deleteTaskContext(taskId)。这是防止“半成品任务”堆积的最后防线。如果一个任务在存入上下文后、返回响应前崩溃,Redis 中会残留一个无法被续跑的脏数据。主动清理,是保障系统健康的底线。
3.4 AgentBoard 集成:让 Agent “活”在统一控制台
如何把 agent 暴露出 a2a agentcoard是当前热点。AgentBoard 本质是一个 A2A 协议的可视化网关和任务调度中心。要让ComplaintProcessorAgent被它识别,只需两步:
- 暴露
/a2a/metadata端点:返回 Agent 的能力描述。
@GetMapping("/a2a/metadata") public AgentMetadata getMetadata() { return AgentMetadata.builder() .name("ComplaintProcessorAgent") .version("1.0.0") .description("处理用户投诉,支持自动分析与人工审批协同") .capabilities(Arrays.asList( new Capability("processComplaint", "POST", "/a2a/process-complaint"), new Capability("resumeTask", "POST", "/a2a/resume-task") )) .build(); }- 实现
/a2a/task/{taskId}/status端点:供 AgentBoard 查询任务状态。
@GetMapping("/a2a/task/{taskId}/status") public TaskStatus getTaskStatus(@PathVariable String taskId) { try { // 尝试从 Task Store 加载上下文 taskStoreService.getTaskContext(taskId, ComplaintContext.class); return new TaskStatus(taskId, "INPUT_REQUIRED", System.currentTimeMillis()); } catch (TaskNotFoundException e) { // 如果上下文不存在,检查是否已完成(可通过数据库查询最终结果) if (isTaskCompletedInDB(taskId)) { return new TaskStatus(taskId, "SUCCESS", System.currentTimeMillis()); } else { return new TaskStatus(taskId, "NOT_FOUND", System.currentTimeMillis()); } } }AgentBoard 会定期轮询这个端点,一旦状态变为INPUT_REQUIRED,就在控制台上显示为“待审批”,并提供表单供运营人员填写。填写后,AgentBoard 调用resumeTask,完成闭环。
4. C++ Agent 实现要点与性能优化:当低延迟与高吞吐成为刚需
当你的 Agent 需要处理实时音视频流分析、高频金融交易决策或自动驾驶感知融合时,Java 的 GC 停顿和 JVM 启动开销就成了瓶颈。C++ 成为必然选择。但 C++ 实现 A2AINPUT_REQUIRED机制,挑战远大于 Java。以下是核心要点与实战经验。
4.1 内存管理:避免“悬垂指针”与“内存泄漏”的双重陷阱
在 C++ 中,taskId关联的上下文(ComplaintContext)通常是一个堆上分配的对象。INPUT_REQUIRED返回后,这个对象不能被delete,否则续跑时访问就是野指针;也不能一直new不delete,否则内存泄漏。解决方案是智能指针 + 弱引用计数。
#include <memory> #include <unordered_map> #include <mutex> class TaskStore { private: std::unordered_map<std::string, std::shared_ptr<ComplaintContext>> store_; mutable std::mutex mutex_; public: // 存储:增加 shared_ptr 的引用计数 void storeTaskContext(const std::string& taskId, std::shared_ptr<ComplaintContext> context) { std::lock_guard<std::mutex> lock(mutex_); store_[taskId] = context; // 设置 TTL:启动一个异步定时器,到期后调用 erase startTtlTimer(taskId, 3600); } // 获取:返回 shared_ptr 的拷贝,确保对象存活 std::shared_ptr<ComplaintContext> getTaskContext( const std::string& taskId) { std::lock_guard<std::mutex> lock(mutex_); auto it = store_.find(taskId); if (it != store_.end()) { return it->second; // 返回拷贝,引用计数+1 } return nullptr; } // 续跑完成:手动释放 void deleteTaskContext(const std::string& taskId) { std::lock_guard<std::mutex> lock(mutex_); store_.erase(taskId); } };关键原理与避坑点:
shared_ptr是生命线:getTaskContext返回的是shared_ptr的拷贝,这意味着只要有一个shared_ptr拷贝存在,底层对象就不会被销毁。续跑逻辑拿到这个拷贝后,可以安全地访问和修改其成员变量。weak_ptr用于定时器:startTtlTimer内部应使用std::weak_ptr来持有ComplaintContext,因为定时器回调是异步的,可能在shared_ptr已被释放后才触发。weak_ptr.lock()可以安全地检查对象是否还活着,避免访问已释放内存。std::mutex的粒度:不要用一个全局锁锁住整个store_。对于高并发场景,应使用分段锁(Sharded Lock)或无锁数据结构(如folly::AtomicUnorderedMap),否则mutex_会成为性能瓶颈。
4.2 序列化:从 Protocol Buffers 到 Zero-Copy 的极致优化
C++ Agent 对序列化的要求是:快、小、跨语言。JSON 解析慢,std::string构造开销大。首选 Protocol Buffers(Protobuf)。
定义complaint_context.proto:
syntax = "proto3"; package a2a; message ComplaintContext { string complaint_id = 1; string user_id = 2; int32 estimated_compensation = 3; string initial_analysis = 4; // ... 其他字段 }在 C++ 代码中:
// 存储时:序列化为二进制 void TaskStore::storeTaskContext(const std::string& taskId, const ComplaintContext& context) { std::string serialized; context.SerializeToString(&serialized); // Zero-copy, fast // 将 serialized 存入 Redis 或本地 LMDB } // 获取时:反序列化 ComplaintContext TaskStore::getTaskContext(const std::string& taskId) { std::string serialized = getFromStorage(taskId); // 从存储读取二进制 ComplaintContext context; context.ParseFromString(serialized); // Zero-copy, fast return context; }关键原理与避坑点:
- Zero-Copy 的威力:
SerializeToString和ParseFromString在 Protobuf 的 C++ 实现中,是高度优化的,避免了中间字符串拷贝。相比 JSON 的nlohmann::json::parse,性能提升 3-5 倍。 - 内存池(Memory Pool):对于高频创建/销毁
ComplaintContext的场景,应使用内存池(如boost::pool或自定义 slab allocator)。每次new都从池中分配,delete时归还,避免频繁调用malloc/free的系统开销。 - 避免
std::string的隐式拷贝:在函数参数传递中,始终使用const std::string&,而不是std::string。后者会触发深拷贝,对大字符串(如长文本分析结果)是灾难性的。
4.3 网络层:用 gRPC 替代 REST,拥抱 A2A 的原生协议
A2A 协议的精髓在于语义化,而 REST 的 HTTP/1.1 是为文档传输设计的,头部冗余、文本解析慢。gRPC 基于 HTTP/2,使用 Protobuf 作为 IDL,天生就是 A2A 的理想载体。
定义a2a_service.proto:
service A2AAgent { // 标准 A2A 处理入口 rpc ProcessComplaint (ComplaintRequest) returns (A2AResponse); // 续跑入口 rpc ResumeTask (ResumeTaskRequest) returns (A2AResponse); // 元数据查询 rpc GetMetadata (google.protobuf.Empty) returns (AgentMetadata); } message A2AResponse { enum Status { SUCCESS = 0; INPUT_REQUIRED = 1; ERROR = 2; } Status status = 1; string task_id = 2; oneof data { ComplaintResult success_data = 3; InputRequirements input_requirements = 4; string error_message = 5; } }关键原理与避坑点:
- HTTP/2 的多路复用:一个 gRPC 连接可以承载成百上千个并发 RPC,避免了 HTTP/1.1 的连接风暴。这对于 AgentBoard 频繁查询数百个
taskId的状态,是巨大的性能提升。 - 流式响应(Streaming):对于长周期任务(如视频转码),
ProcessComplaint可以返回stream A2AResponse,实时推送进度({status: "PROCESSING", progress: 35}),而不仅仅是最终的INPUT_REQUIRED。这比轮询优雅得多。 - C++ gRPC 的线程模型:务必使用
CompletionQueue模式,而非同步阻塞模式。CompletionQueue是 gRPC C++ 的高性能基石,它将网络 I/O 和业务逻辑解耦,让你的 Agent 能轻松处理数千 QPS。
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
在将INPUT_REQUIRED机制落地到十几个不同业务线的过程中,我们踩过无数坑。以下是最典型、最高频、最让人抓狂的五个问题,以及我们总结出的“秒级定位”排查法。
5.1 问题:taskId在续跑时“找不到”,但 Redis 里明明有数据
现象:前端提交了审批表单,AgentBoard 调用resumeTask,返回{"status":"ERROR","errorMessage":"Task context not found or expired"}。然而,运维同学用redis-cli直连,执行HGETALL a2a:task:a1b2c3d4-5678-90ef-ghij-klmnopqrstuv,数据完好无损。
排查思路与根因:
- 第一步:检查序列化/反序列化一致性。这是 90% 的原因。Java Agent 用 Jackson 存,C++ Agent 用 Protobuf 读,字节流不兼容。
HGETALL看到的是乱码,但redis-cli默认以字符串显示,掩盖了二进制本质。 - 第二步:检查 Key 的前缀。
TASK_HASH_PREFIX在 Java 和 C++ 代码中是否完全一致?一个多了空格,一个少了冒号,就会导致 Key 不匹配。用KEYS a2a:task:*查看实际存在的 Key。 - 第三步:检查
getTaskContext的实现。C++ 代码中,getFromStorage(taskId)是否真的从 Redis 的 Hash 结构中读取了context字段?还是错误地读取了整个 Hash?HGET和HGETALL的语义差异是致命的。
独家技巧:在TaskStoreService的getTaskContext方法开头,加一行日志:log.debug("Attempting to get context for taskId: {}, full key: {}", taskId, TASK_HASH_PREFIX + taskId);。然后在 Redis 中执行KEYS命令,对比日志中的full key和实际存在的 Key。肉眼可见的差异,往往就是问题所在。
5.2 问题:INPUT_REQUIRED返回了,但 AgentBoard 上任务状态一直是“处理中”
现象:processComplaint接口返回了正确的INPUT_REQUIRED响应,taskId也正确。但 AgentBoard 的 UI 上,该任务的状态图标一直旋转,从未变成“待审批”。
排查思路与根因:
- 根因 1:AgentBoard 的轮询间隔过长。默认配置可能是 30 秒轮询一次
/a2a/task/{id}/status。而你的 Agent 在返回INPUT_REQUIRED后,可能 1 秒内就完成了上下文存储。这 1 秒的窗口,AgentBoard 就错过了。 - 根因 2:
/a2a/task/{id}/status端点返回了错误的状态码。AgentBoard 通常只信任200 OK。如果你的端点在getTaskContext抛出异常时,返回了500 Internal Server Error,AgentBoard 会认为“查询失败”,并重试,但不会改变 UI 状态。 - 根因 3:AgentBoard 的缓存。某些版本的 AgentBoard 会对
/status接口做客户端缓存(Cache-Control: max-age=10),导致即使后端已更新,前端看到的仍是旧状态。
独家技巧:打开浏览器开发者工具,切换到 Network 标签页,手动访问http://your-agent:8080/a2a/task/a1b2c3d4-5678-90ef-ghij-klmnopqrstuv/status。观察返回的 HTTP 状态码(必须是200)和响应体中的status字段(必须是"INPUT_REQUIRED")。如果一切正常,再检查 AgentBoard 的控制台日志,搜索status polling关键字,确认它是否真的在调用这个 URL。
5.3 问题:续跑成功,但业务结果“丢失”了,数据库里没记录
现象:resumeTask接口返回SUCCESS,日志显示ComplaintResult已生成。但查询数据库,对应的投诉单状态仍是PENDING_APPROVAL,没有变成PROCESSED。
排查思路与根因:
- 根因:事务边界错误。
resumeTask方法里,finalizeComplaint(context)可能开启了新的数据库事务,但这个事务没有被 Spring 的@Transactional注解管理。当方法执行完,事务自动提交,但finalizeComplaint内部的 DAO 操作却是在一个未被管理的、独立的 JDBC Connection 上执行的,因此不会提交。 - 根因:异步操作未等待。
finalizeComplaint内部调用了sendNotificationAsync(),这是一个@Async方法。resumeTask方法在sendNotificationAsync()还没执行完时就返回了SUCCESS,导致主事务提交,但异步任务失败了,无人知晓。
独家技巧:在finalizeComplaint方法的第一行,加一个