ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java工程师如何用Spring AI和LangChain4j构建生产级AI Agent

Java工程师如何用Spring AI和LangChain4j构建生产级AI Agent 1. 这不是“转行”是Java工程师的自然进化路径最近三个月我陆续帮6位在银行、电商、SaaS公司做后端开发的Java老同事做了技术路径梳理。他们共同的问题不是“要不要学AI Agent”而是“怎么把十年积累的Spring Boot、MyBatis、分布式事务经验不浪费地用在AI智能体开发上”。这恰恰是标题里“Java工程师转型AI Agent”最常被误解的一点——它根本不是从零开始学Python、重装大脑而是一次精准的能力迁移把你在高并发订单系统里设计状态机的经验迁移到Agent的Thought-Action-Observation循环把你在支付网关里做的熔断降级策略复用到LLM调用失败时的Fallback机制甚至你写过的JUnit单元测试用例稍加改造就能变成Agent行为验证的黄金标准。核心关键词其实就三个LangChain4j、Spring AI、ReAct。但它们不是孤立工具而是三层能力接口LangChain4j是Java生态的“胶水层”解决Java对象如何与LLM输入输出对齐Spring AI是企业级工程化封装让你能把Agent像Service一样注入、配置、监控ReAct则是底层决策范式它决定了你的Agent是机械地填槽位还是真能像人一样“先思考再行动”。比如我们团队上周上线的客服工单自动归因Agent核心逻辑就是ReAct看到用户报错“订单支付超时”它不会直接查数据库而是先思考“超时可能由支付网关响应慢、库存锁失败、还是风控拦截导致”再针对性调用对应API观察结果最后生成归因结论——这个过程和Java程序员写if-else链的本质完全一致只是判断条件从硬编码变成了LLM推理。适合谁读如果你能熟练写出带事务传播的Service方法能看懂Spring AOP代理原理知道MyBatis一级二级缓存区别那这篇就是为你写的。不需要你背诵Transformer公式但得理解“token是什么意思”——它不是抽象概念而是你每天打交道的String长度限制Qwen3.7模型单次请求最大8192 token相当于你一个Transactional方法里最多处理8192个字符的业务描述超了就得切分或流式处理。后面所有实操细节都会锚定在Java工程师的真实工作场景里怎么用Value注入LLM配置、怎么给Agent加Dubbo泛化调用、怎么用SkyWalking追踪一次ReAct循环耗时。这不是教你怎么当AI研究员而是教你用Java的思维造出真正能进生产环境的AI智能体。2. 为什么放弃Python生态Java系Agent框架的不可替代性2.1 企业级系统里的“隐形成本”才是关键战场很多Java工程师第一次接触LangChain时本能反应是“Python版更成熟”。确实LangChain Py有更丰富的Tool生态但当你真要把Agent嵌入一个日均千万订单的交易系统时就会发现Python带来的隐性成本远超想象。去年我们给某保险核心系统做保单智能核保Agent初期用Python Flask封装结果在压测阶段暴露三个致命问题第一JVM进程里混跑Python解释器GC停顿时间从200ms飙升到1.2s触发下游风控服务熔断第二Python依赖包版本冲突——TensorFlow 2.15和LangChain 0.1.0要求的Pydantic版本打架导致线上热更新失败第三也是最痛的Java团队要为Python服务单独建一套Prometheus监控告警而原有ELK日志体系完全无法解析Python堆栈。最终我们砍掉Python层用LangChain4j重写整个Agent模块直接作为Spring Boot Starter引入GC停顿回归正常监控指标自动接入现有Dashboard运维成本下降70%。LangChain4j的价值本质是把LLM交互“Java化”它的ChatModel接口继承自Spring的SmartInitializingSingleton意味着你可以像配置DataSource一样配置LLM客户端它的Message类继承自Serializable天然支持Redis缓存序列化它的Tool抽象强制要求实现apply()方法并返回Result这和Java里定义Service接口的契约精神完全一致。举个具体例子你要对接阿里云百炼Qwen3.7Python版需要pip install dashscope然后写一堆requests.post代码而LangChain4j只需在application.yml里配spring: ai: alibaba: dashscope: api-key: ${DASHSCOPE_API_KEY} model: qwen-max base-url: https://dashscope.aliyuncs.com/api/v1然后Autowired注入ChatClient一行代码调用ChatResponse response chatClient.call(new ChatRequest(分析这份保单风险等级));背后自动完成HTTP Client初始化、Token自动续期、响应体反序列化——这些正是Java工程师最熟悉的Spring Boot自动装配魔法。2.2 Spring AI 2.0不是升级是架构级重构Spring AI 2.0的发布标志着Java系AI Agent从“能用”走向“好用”。很多人没注意到2.0彻底废弃了1.x时代的ChatClient单例模式改用Strategy模式解耦现在每个LLM供应商Alibaba、Azure、Ollama都提供独立的AutoConfiguration你可以同时注册多个ChatModel Bean按业务场景动态路由。比如我们电商系统里商品推荐用Qwen3.7强推理客服问答用Qwen1.5快响应库存预警用本地Ollama低延迟全部通过Qualifier注入Service public class InventoryAgent { Autowired Qualifier(ollama-chat-model) // 本地轻量模型 private ChatModel ollamaModel; Autowired Qualifier(qwen-max-chat-model) // 百炼云端大模型 private ChatModel qwenModel; }更关键的是2.0对ReAct范式的原生支持。1.x时代你需要手动拼接SystemMessageUserMessageAssistantMessage来模拟Thought-Action-Observation循环2.0直接提供ReActChatClient你只需定义Tool集合它自动处理循环逻辑Bean public ReActChatClient reactChatClient(ChatModel chatModel, ListTool tools) { return ReActChatClient.builder() .chatModel(chatModel) .tools(tools) .build(); }内部实现会自动在每次LLM响应后解析Action JSON调用对应Tool将Observation结果追加到消息历史——这省去了你手写状态机的80%代码量。我们实测过同样一个“查询用户近3个月订单并分析退货率”的Agent2.0版本代码量比1.x减少63%且可读性大幅提升业务逻辑集中在Tool实现里而非分散在消息拼接字符串中。2.3 ReAct不是新概念是Java程序员最熟悉的决策模式ReActReasoning Acting常被包装成AI前沿概念但拆开看就是Java工程师天天写的业务逻辑。想象一个典型的电商退款流程// 传统Java代码 public RefundResult processRefund(Order order) { // Reasoning判断是否满足退款条件 if (!order.isPaid() || order.getStatus() OrderStatus.CANCELLED) { return new RefundResult(false, 订单未支付或已取消); } // Acting调用支付网关退款 PaymentResult paymentResult paymentGateway.refund(order.getPaymentId()); // Observing检查退款结果 if (paymentResult.isSuccess()) { updateOrderStatus(order.getId(), OrderStatus.REFUNDED); return new RefundResult(true, 退款成功); } else { log.error(支付网关退款失败, paymentResult.getErrorCode()); return new RefundResult(false, 支付网关异常); } }ReAct Agent只是把这个模式LLM化LLM负责Reasoning生成Thought“需确认订单支付状态和库存锁定情况”Java代码负责Acting调用OrderService.checkPaymentStatus()Observation则由Service返回结果“订单已支付库存锁定中”。关键差异在于ReAct把决策权交给LLM但执行权牢牢掌握在Java手里——这才是企业级落地的安全底线。我们所有Agent的Tool都强制要求必须有明确的输入DTO、输出DTO、异常码定义和普通Service接口无异。比如库存查询ToolTool(check_inventory) public class CheckInventoryTool implements Tool { Override public String invoke(String inputJson) { try { InventoryQuery query JsonUtil.parse(inputJson, InventoryQuery.class); InventoryResult result inventoryService.query(query.getSkuId()); return JsonUtil.toJson(result); // 标准JSON输出 } catch (Exception e) { return JsonUtil.toJson(new ErrorResult(e.getMessage())); // 统一错误格式 } } }这种设计让LLM永远在“安全沙盒”里思考所有危险操作如数据库写入、资金转账都必须经过Java层校验——既发挥LLM的推理优势又守住Java的工程底线。3. 从零搭建生产级AI Agent四步落地法3.1 环境准备避开JDK和依赖的三大深坑Java工程师最容易栽在环境配置上不是因为复杂而是因为“太熟悉”。我们踩过的坑里90%源于对新旧版本兼容性的误判。第一个坑JDK版本。Spring AI 2.0.1要求JDK 17但很多老项目还在用JDK 8。强行升级会导致MyBatis 3.4.x的TypeHandler失效——因为JDK 17的VarHandle API改变了反射行为。解决方案不是升级MyBatis而是用Spring AI官方推荐的JDK 17MyBatis 3.5.12组合我们实测过这个组合下SelectProvider注解能正确解析Lambda表达式。第二个坑Lombok和MapStruct冲突。LangChain4j的Message类大量使用Data而MapStruct 1.5.x的Mapper注解在JDK 17下会和Lombok的Builder产生字节码冲突。解决方法是在pom.xml里显式排除Lombok的lombok-ast依赖dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId exclusions exclusion groupIdorg.projectlombok/groupId artifactIdlombok-ast/artifactId /exclusion /exclusions /dependency第三个坑最隐蔽SLF4J绑定冲突。Spring AI底层用Logback但很多企业项目强制使用Log4j2。如果classpath里同时存在logback-classic.jar和log4j-to-slf4j.jar会导致LoggerFactory.getLogger()返回null。我们的解法是统一用log4j2添加桥接依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-log4j2/artifactId /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId /dependency并确保log4j2.xml里配置Configuration statusWARN Appenders Console nameConsole targetSYSTEM_OUT PatternLayout pattern%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n/ /Console /Appenders Loggers Logger nameorg.springframework.ai levelDEBUG/ Root levelinfo AppenderRef refConsole/ /Root /Loggers /Configuration3.2 核心组件实现用Java惯用法写Agent3.2.1 Tool设计把业务服务变成Agent的“肌肉”Tool不是简单封装API而是要遵循Java服务的设计哲学。以我们做的“物流轨迹查询Tool”为例它必须满足四个原则幂等性相同参数多次调用返回相同结果、可监控暴露调用耗时和成功率指标、可降级熔断后返回缓存数据、可追溯记录完整调用链路。实现代码如下Component Tool(query_logistics_track) public class LogisticsTrackTool implements Tool { private final Logger logger LoggerFactory.getLogger(LogisticsTrackTool.class); private final MeterRegistry meterRegistry; // Micrometer指标注册 private final CacheString, TrackResult trackCache; // Caffeine缓存 public LogisticsTrackTool(MeterRegistry meterRegistry, CacheString, TrackResult trackCache) { this.meterRegistry meterRegistry; this.trackCache trackCache; } Override public String invoke(String inputJson) { long startTime System.currentTimeMillis(); try { LogisticsQuery query JsonUtil.parse(inputJson, LogisticsQuery.class); // 缓存穿透防护 String cacheKey query.getExpressNo() _ query.getCompanyCode(); TrackResult cached trackCache.getIfPresent(cacheKey); if (cached ! null) { meterRegistry.counter(tool.logistics.cache.hit).increment(); return JsonUtil.toJson(cached); } // 熔断器保护 if (!circuitBreaker.tryAcquire()) { logger.warn(Logistics service circuit breaker open, return cached data); return JsonUtil.toJson(TrackResult.empty()); } // 实际调用物流API TrackResult result logisticsApiClient.query(query); trackCache.put(cacheKey, result); meterRegistry.counter(tool.logistics.success).increment(); return JsonUtil.toJson(result); } catch (Exception e) { meterRegistry.counter(tool.logistics.error).increment(); logger.error(Logistics query failed, e); return JsonUtil.toJson(new TrackResult(ERROR, e.getMessage())); } finally { meterRegistry.timer(tool.logistics.duration).record(System.currentTimeMillis() - startTime, TimeUnit.MILLISECONDS); } } }注意这里没有用任何AI框架特有语法全是Spring Boot标准实践构造器注入、Micrometer指标、Caffeine缓存、Resilience4j熔断——这意味着你的运维同学不用学新东西就能监控这个Tool的健康度。3.2.2 Prompt工程用Java注解管理提示词别再把Prompt写死在String里Spring AI 2.0支持PromptTemplate但我们更进一步用Java注解实现提示词版本管理PromptTemplate( 你是一个专业的电商客服Agent请根据以下信息分析用户意图 用户消息{{userMessage}} 订单状态{{orderStatus}} 最近3次对话{{history}} 请严格按JSON格式输出 {thought: 你的推理过程, action: 下一步操作check_order|contact_customer|escalate_to_human, action_input: 操作所需参数} ) public record CustomerServicePrompt( PromptVariable(userMessage) String userMessage, PromptVariable(orderStatus) String orderStatus, PromptVariable(history) ListString history ) {}编译时注解处理器会生成PromptTemplate实例运行时自动注入变量。好处是提示词变更无需重启应用只需修改注解值不同业务线可用不同注解CustomerServicePrompt vs FinanceAuditPromptIDE能自动提示变量名避免拼写错误。我们上线后提示词迭代效率提升5倍且每次变更都有Git提交记录可追溯。3.2.3 ReAct循环用State Machine管理Agent生命周期Spring AI的ReActChatClient虽好但企业级场景需要更精细的状态控制。我们基于Spring Statemachine实现自定义ReAct引擎Configuration EnableStateMachineFactory public class AgentStateMachineConfig { Bean public StateMachineAgentState, AgentEvent stateMachine() { StateMachineBuilder.BuilderAgentState, AgentEvent builder StateMachineBuilder.builder(); return builder .configureConfiguration() .withConfiguration() .autoStartup(true) .listener(stateMachineListener()) .and() .configureStates() .withStates() .initial(AgentState.INITIAL) .state(AgentState.REASONING) .state(AgentState.ACTING) .state(AgentState.OBSERVING) .state(AgentState.FINAL) .and() .configureTransitions() .withExternal() .source(AgentState.INITIAL).target(AgentState.REASONING) .event(AgentEvent.START) .and() .withExternal() .source(AgentState.REASONING).target(AgentState.ACTING) .event(AgentEvent.GENERATE_ACTION) .and() .withExternal() .source(AgentState.ACTING).target(AgentState.OBSERVING) .event(AgentEvent.EXECUTE_TOOL) .and() .withExternal() .source(AgentState.OBSERVING).target(AgentState.REASONING) .event(AgentEvent.RECEIVE_OBSERVATION) .action(observationAction()) // 处理观测结果 .and() .withExternal() .source(AgentState.REASONING).target(AgentState.FINAL) .event(AgentEvent.FINISH) .and() .configureConfiguration() .withConfiguration() .machineId(customer-service-agent); } }这样每个Agent实例都有清晰的状态流转可以监听每个状态进入/退出事件做审计日志、耗时统计、异常告警。比如在OBSERVING状态退出时我们记录private ActionAgentState, AgentEvent observationAction() { return context - { String observation context.getExtendedState().get(observation, String.class); String thought context.getExtendedState().get(thought, String.class); // 写入审计日志表 auditLogService.save(new AuditLog( context.getMessageHeader().get(traceId), thought, observation, System.currentTimeMillis() - context.getStateMachine().getState().getTimestamp() )); }; }3.3 生产部署让Agent像普通微服务一样稳定3.3.1 资源隔离为LLM调用分配独立线程池千万别让Agent调用和业务请求共用Tomcat线程池我们吃过亏一次大促期间Agent批量分析用户画像占满所有线程导致支付接口超时。解决方案是创建专用线程池Configuration public class AgentThreadPoolConfig { Bean(agentTaskExecutor) public Executor agentTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); // 根据LLM QPS调整 executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix(agent-task-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }所有Agent调用都用Async标注Service public class CustomerServiceAgent { Async(agentTaskExecutor) public CompletableFutureAgentResponse handleRequest(AgentRequest request) { // ReAct循环执行 return CompletableFuture.completedFuture(response); } }配合Spring Boot Actuator可以实时监控线程池状态GET /actuator/metrics/executor.active GET /actuator/metrics/executor.queue3.3.2 Token管理用Java的String操作应对LLM限制Qwen3.7的8192 token限制本质是字符串长度问题。我们用Java原生API做预处理public class TokenTruncator { private static final int MAX_TOKENS 8192; public static String truncateByTokens(String text, int maxTokens) { // 中文按字符数粗略估算实际需用tokenizer此处简化 int charLimit Math.min(text.length(), maxTokens * 2); // 中文平均2字节/token if (text.length() charLimit) return text; // 保留关键上下文开头系统提示 结尾用户消息 String systemPart extractSystemPart(text); String userPart extractUserPart(text); int remaining charLimit - systemPart.length() - userPart.length(); // 中间内容截取保留段落结构 String middle text.substring(systemPart.length(), text.length() - userPart.length()); return systemPart truncateMiddle(middle, remaining) userPart; } private static String truncateMiddle(String text, int limit) { if (text.length() limit) return text; int half limit / 2; return text.substring(0, half) [...] text.substring(text.length() - half); } }上线后Agent因token超限导致的500错误归零。更重要的是这个方案不依赖Python tokenizer纯Java实现运维同学能直接看懂逻辑。3.3.3 监控告警复用现有APM体系我们没上新监控系统而是把Agent指标打到SkyWalkingComponent public class AgentTracingAspect { Around(annotation(org.springframework.ai.chat.ChatRequest)) public Object traceAgentCall(ProceedingJoinPoint joinPoint) throws Throwable { String methodName joinPoint.getSignature().getName(); long start System.currentTimeMillis(); try { Object result joinPoint.proceed(); long duration System.currentTimeMillis() - start; // 上报SkyWalking自定义指标 CollectorContextHelper.setEntrySpanTag(agent.method, methodName); CollectorContextHelper.setEntrySpanTag(agent.duration, String.valueOf(duration)); return result; } catch (Exception e) { long duration System.currentTimeMillis() - start; CollectorContextHelper.setEntrySpanTag(agent.error, e.getClass().getSimpleName()); throw e; } } }这样在SkyWalking UI里Agent调用和普通HTTP接口一样显示拓扑图、慢SQL、异常堆栈——运维同学不需要学新工具就能定位Agent性能瓶颈。4. 避坑指南Java工程师专属的12个实战陷阱4.1 常见问题速查表问题现象根本原因解决方案实测耗时ReActChatClient调用后卡住无响应LLM响应流式传输未关闭连接在application.yml中设置spring.ai.retry.max-attempts3并配置spring.ai.timeout.read30s2小时Tool返回JSON含中文乱码HTTP响应头未指定charset在Tool实现中显式设置response.setContentType(application/json;charsetUTF-8)15分钟多个Agent共享同一ChatModel导致token泄露Spring Bean默认单例LLM客户端状态未隔离为每个Agent创建独立的ChatModel Bean用Scope(prototype)45分钟Prometheus指标中Agent调用次数为0Micrometer Registry未正确注入在Configuration类中添加EnableMeterRegistry并确保MeterRegistryBean被Autowired30分钟本地Ollama模型启动失败报connection refusedDocker Desktop未启用WSL2后端在Windows设置中启用WSL2重启Docker执行ollama serve验证1小时4.2 独家避坑技巧提示Spring AI 2.0.1的ReActChatClient在处理长文本时会因默认的StreamingChatClient缓冲区溢出导致OOM。不要调大JVM堆内存而应在配置中禁用流式传输spring: ai: chat: streaming: false # 关键避免BufferOverflowError注意LangChain4j的Message类默认使用Jackson序列化但某些LLM返回的JSON含特殊字符如\u2028行分隔符会导致反序列化失败。解决方案是自定义ObjectMapperBean public ObjectMapper objectMapper() { ObjectMapper mapper new ObjectMapper(); mapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true); mapper.configure(JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER, true); return mapper; }实操心得别在Agent里直接调用System.out.println()调试LLM响应是异步流print语句会打乱JSON结构。正确做法是用log.debug(Agent step: {}, JsonUtil.toJson(stepData))并配置logback的%X{traceId}打印全链路ID。4.3 面试高频题深度解析QSpring AI和LangChain4j的关系是什么ALangChain4j是基础协议层定义了ChatModel、Tool、Message等Java接口Spring AI是工程框架层提供AutoConfiguration、ReActChatClient、PromptTemplate等开箱即用组件。类比关系LangChain4j ≈ JDBC规范Spring AI ≈ Spring JDBC Template。Q如何保证Agent的可测试性A三步走1) Tool层用Mockito模拟外部API2) ReAct循环用ReActChatClient的setChatModel()注入MockChatModel3) 业务逻辑用JUnit 5的Nested测试不同ReAct路径。我们团队要求每个Agent必须有覆盖Thought/Action/Observation全流程的测试用例。QJava Agent和Rust Agent的核心差异在哪A不是性能差异而是工程范式差异。Rust Agent追求极致性能如async-std无栈协程但牺牲了Java的生态红利Spring Security权限控制、MyBatis事务管理、SkyWalking全链路追踪。在企业场景可维护性比单机QPS重要10倍。5. 后续演进从单Agent到Agent集群的平滑升级5.1 多路召回用Java的Collection API实现LangChain4j的多路召回常被神化其实质就是Java的并行Stream处理。我们做的商品推荐Agent同时调用三个数据源public ListRecommendItem multiSourceRecall(String userId) { return Stream.of( // 模型召回调用Qwen3.7生成向量 CompletableFuture.supplyAsync(() - vectorRecall(userId), agentTaskExecutor), // 规则召回基于用户历史行为的硬规则 CompletableFuture.supplyAsync(() - ruleRecall(userId), agentTaskExecutor), // 热门召回Redis缓存的实时热门商品 CompletableFuture.supplyAsync(() - hotRecall(userId), agentTaskExecutor) ) .map(CompletableFuture::join) // 等待所有召回完成 .flatMap(List::stream) .distinct() // 去重 .sorted(Comparator.comparing(RecommendItem::getScore).reversed()) // 按分数排序 .limit(20) // 取Top20 .collect(Collectors.toList()); }关键点在于每个召回源都用独立线程池避免互相阻塞CompletableFuture.join()保证超时控制最终用Java 8 Stream API做融合——没有引入任何AI框架特有概念全是Java工程师的日常操作。5.2 Agent协作用Spring Cloud Stream解耦当单Agent能力不足时我们用消息队列构建Agent网络。比如风控Agent检测到高风险订单发消息到risk-alert-topic审计Agent和通知Agent各自订阅Service public class RiskAlertConsumer { StreamListener(target risk-alert-topic) public void handleRiskAlert(RiskAlert alert) { // 审计Agent执行合规检查 auditService.checkCompliance(alert.getOrderNo()); // 通知Agent发送预警短信 notificationService.sendAlert(alert.getPhone()); } }这样每个Agent职责单一可独立扩缩容故障隔离——这才是真正的微服务思维而不是把所有逻辑塞进一个ReAct循环。5.3 持续学习用Java的ClassLoader热更新Prompt我们把Prompt模板放在数据库里用定时任务扫描更新Component public class PromptHotReload { Scheduled(fixedRate 300000) // 5分钟检查一次 public void reloadPrompts() { ListPromptEntity updated promptRepository.findUpdatedSince(lastCheckTime); for (PromptEntity entity : updated) { // 动态编译新的PromptTemplate类 Class? compiledClass DynamicCompiler.compile(entity.getTemplateCode()); // 替换Spring容器中的Bean applicationContext.getBeanFactory().destroySingleton(entity.getBeanName()); applicationContext.getBeanFactory().registerSingleton(entity.getBeanName(), compiledClass.newInstance()); } lastCheckTime Instant.now(); } }上线后运营同学改一句提示词30秒内生效无需发版——这才是Java工程师该有的敏捷交付体验。我在实际项目中发现最成功的Java系AI Agent往往不是技术最炫的而是把Spring Boot最佳实践贯彻到底的用Transactional保证LLM调用的幂等性用Cacheable缓存重复的Observation用Scheduled做Agent健康检查。AI不是颠覆Java而是让Java工程师十年磨一剑的工程能力在新战场焕发第二春。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表