一、项目背景与架构约束
1.1 为什么 SAP On-Premise 需要 AI Agent
SAP Joule for Developers 仅对 S/4HANA Cloud Public Edition 开放,ECC 6.0 和 S/4HANA On-Premise 部署的企业无法使用官方 AI 服务。但企业内部数据安全和合规性要求,又使得在企业内网部署 AI Agent 成为必然选择。大量本地部署 SAP 系统的客户,将通过自建 AI Agent 重构 SAP 业务和流程。
1.2 核心架构约束:On-Premise 网络边界
SAP On-Premise 系统通常部署在企业内网,与外部 AI Agent 平台之间存在天然的网络边界。这个约束决定了集成方案的选型:
#mermaid-svg-iGHSiO2ygqKKGZIC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iGHSiO2ygqKKGZIC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iGHSiO2ygqKKGZIC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iGHSiO2ygqKKGZIC .error-icon{fill:#552222;}#mermaid-svg-iGHSiO2ygqKKGZIC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iGHSiO2ygqKKGZIC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iGHSiO2ygqKKGZIC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iGHSiO2ygqKKGZIC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iGHSiO2ygqKKGZIC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iGHSiO2ygqKKGZIC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iGHSiO2ygqKKGZIC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iGHSiO2ygqKKGZIC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iGHSiO2ygqKKGZIC .marker.cross{stroke:#333333;}#mermaid-svg-iGHSiO2ygqKKGZIC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iGHSiO2ygqKKGZIC p{margin:0;}#mermaid-svg-iGHSiO2ygqKKGZIC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iGHSiO2ygqKKGZIC .cluster-label text{fill:#333;}#mermaid-svg-iGHSiO2ygqKKGZIC .cluster-label span{color:#333;}#mermaid-svg-iGHSiO2ygqKKGZIC .cluster-label span p{background-color:transparent;}#mermaid-svg-iGHSiO2ygqKKGZIC .label text,#mermaid-svg-iGHSiO2ygqKKGZIC span{fill:#333;color:#333;}#mermaid-svg-iGHSiO2ygqKKGZIC .node rect,#mermaid-svg-iGHSiO2ygqKKGZIC .node circle,#mermaid-svg-iGHSiO2ygqKKGZIC .node ellipse,#mermaid-svg-iGHSiO2ygqKKGZIC .node polygon,#mermaid-svg-iGHSiO2ygqKKGZIC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iGHSiO2ygqKKGZIC .rough-node .label text,#mermaid-svg-iGHSiO2ygqKKGZIC .node .label text,#mermaid-svg-iGHSiO2ygqKKGZIC .image-shape .label,#mermaid-svg-iGHSiO2ygqKKGZIC .icon-shape .label{text-anchor:middle;}#mermaid-svg-iGHSiO2ygqKKGZIC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iGHSiO2ygqKKGZIC .rough-node .label,#mermaid-svg-iGHSiO2ygqKKGZIC .node .label,#mermaid-svg-iGHSiO2ygqKKGZIC .image-shape .label,#mermaid-svg-iGHSiO2ygqKKGZIC .icon-shape .label{text-align:center;}#mermaid-svg-iGHSiO2ygqKKGZIC .node.clickable{cursor:pointer;}#mermaid-svg-iGHSiO2ygqKKGZIC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iGHSiO2ygqKKGZIC .arrowheadPath{fill:#333333;}#mermaid-svg-iGHSiO2ygqKKGZIC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iGHSiO2ygqKKGZIC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iGHSiO2ygqKKGZIC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iGHSiO2ygqKKGZIC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iGHSiO2ygqKKGZIC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iGHSiO2ygqKKGZIC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iGHSiO2ygqKKGZIC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iGHSiO2ygqKKGZIC .cluster text{fill:#333;}#mermaid-svg-iGHSiO2ygqKKGZIC .cluster span{color:#333;}#mermaid-svg-iGHSiO2ygqKKGZIC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iGHSiO2ygqKKGZIC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iGHSiO2ygqKKGZIC rect.text{fill:none;stroke-width:0;}#mermaid-svg-iGHSiO2ygqKKGZIC .icon-shape,#mermaid-svg-iGHSiO2ygqKKGZIC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iGHSiO2ygqKKGZIC .icon-shape p,#mermaid-svg-iGHSiO2ygqKKGZIC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iGHSiO2ygqKKGZIC .icon-shape .label rect,#mermaid-svg-iGHSiO2ygqKKGZIC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iGHSiO2ygqKKGZIC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iGHSiO2ygqKKGZIC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iGHSiO2ygqKKGZIC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} AI Agent 平台(内网或私有云)
SAP 内网
DMZ 区(防火墙)
ABAP 引擎
HTTPS 44300
反代 + 限流 + 审计
OData V2 REST(唯一协议)
requests / httpx
本地推理
反向代理 / API Gateway
ECC 6.0 / S/4HANA On-Premise
ICF HTTP 服务
/sap/opu/odata(统一 OData 出口)
AI Agent
LangGraph / LangChain
MCP Server
SAP 工具封装
本地 LLM
Qwen / Llama
1.3 三层架构:从 SAP 能力到 Agent 工具
为了让 AI Agent 能调用 SAP 业务操作,我们需要三层架构------每层解耦,各自可替换:
#mermaid-svg-o0fFfcLTssasHE9b{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-o0fFfcLTssasHE9b .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-o0fFfcLTssasHE9b .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-o0fFfcLTssasHE9b .error-icon{fill:#552222;}#mermaid-svg-o0fFfcLTssasHE9b .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-o0fFfcLTssasHE9b .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-o0fFfcLTssasHE9b .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-o0fFfcLTssasHE9b .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-o0fFfcLTssasHE9b .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-o0fFfcLTssasHE9b .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-o0fFfcLTssasHE9b .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-o0fFfcLTssasHE9b .marker{fill:#333333;stroke:#333333;}#mermaid-svg-o0fFfcLTssasHE9b .marker.cross{stroke:#333333;}#mermaid-svg-o0fFfcLTssasHE9b svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-o0fFfcLTssasHE9b p{margin:0;}#mermaid-svg-o0fFfcLTssasHE9b .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-o0fFfcLTssasHE9b .cluster-label text{fill:#333;}#mermaid-svg-o0fFfcLTssasHE9b .cluster-label span{color:#333;}#mermaid-svg-o0fFfcLTssasHE9b .cluster-label span p{background-color:transparent;}#mermaid-svg-o0fFfcLTssasHE9b .label text,#mermaid-svg-o0fFfcLTssasHE9b span{fill:#333;color:#333;}#mermaid-svg-o0fFfcLTssasHE9b .node rect,#mermaid-svg-o0fFfcLTssasHE9b .node circle,#mermaid-svg-o0fFfcLTssasHE9b .node ellipse,#mermaid-svg-o0fFfcLTssasHE9b .node polygon,#mermaid-svg-o0fFfcLTssasHE9b .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-o0fFfcLTssasHE9b .rough-node .label text,#mermaid-svg-o0fFfcLTssasHE9b .node .label text,#mermaid-svg-o0fFfcLTssasHE9b .image-shape .label,#mermaid-svg-o0fFfcLTssasHE9b .icon-shape .label{text-anchor:middle;}#mermaid-svg-o0fFfcLTssasHE9b .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-o0fFfcLTssasHE9b .rough-node .label,#mermaid-svg-o0fFfcLTssasHE9b .node .label,#mermaid-svg-o0fFfcLTssasHE9b .image-shape .label,#mermaid-svg-o0fFfcLTssasHE9b .icon-shape .label{text-align:center;}#mermaid-svg-o0fFfcLTssasHE9b .node.clickable{cursor:pointer;}#mermaid-svg-o0fFfcLTssasHE9b .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-o0fFfcLTssasHE9b .arrowheadPath{fill:#333333;}#mermaid-svg-o0fFfcLTssasHE9b .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-o0fFfcLTssasHE9b .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-o0fFfcLTssasHE9b .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o0fFfcLTssasHE9b .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-o0fFfcLTssasHE9b .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o0fFfcLTssasHE9b .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-o0fFfcLTssasHE9b .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-o0fFfcLTssasHE9b .cluster text{fill:#333;}#mermaid-svg-o0fFfcLTssasHE9b .cluster span{color:#333;}#mermaid-svg-o0fFfcLTssasHE9b div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-o0fFfcLTssasHE9b .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-o0fFfcLTssasHE9b rect.text{fill:none;stroke-width:0;}#mermaid-svg-o0fFfcLTssasHE9b .icon-shape,#mermaid-svg-o0fFfcLTssasHE9b .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o0fFfcLTssasHE9b .icon-shape p,#mermaid-svg-o0fFfcLTssasHE9b .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-o0fFfcLTssasHE9b .icon-shape .label rect,#mermaid-svg-o0fFfcLTssasHE9b .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o0fFfcLTssasHE9b .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-o0fFfcLTssasHE9b .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-o0fFfcLTssasHE9b :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第三层:Agent 工具层
第二层:协议适配层(单一协议)
第一层:SAP 能力暴露层(对外统一 OData)
SAP 标准 OData API
S/4HANA:API_SALES_ORDER_SRV 等
SEGW 自定义 OData 服务
ECC/S/4:封装 RFC / BAPI / LXE
CDS View
@OData.publish / RAP
OData V2 REST 客户端
requests / httpx + CSRF token
MCP Server
@mcp.tool() 封装
LangChain @tool
函数级封装
LangGraph Node
流程节点
分层原则:
- SAP 能力暴露层 :只在 SAP 系统内部实现,无论底层是标准 API、CDS 还是自定义 RFC,出口统一收口为 OData 服务 (
/sap/opu/odata),不关心谁在调用 - 协议适配层 :纯 Python 实现的单一 OData 客户端,用标准 HTTP 库(
requests/httpx)把 OData JSON 转成 Python dict,统一处理 CSRF token、分页、错误 - Agent 工具层:把适配层的函数包装成 LLM 能理解的工具(带 docstring 和参数 schema),供 LangChain / LangGraph / MCP 注册
1.4 统一协议:为什么对外只暴露 OData
本文所有实践项目遵循一个统一原则:SAP 对外只提供 OData 服务,Agent 端只维护一种协议客户端。
- 一种协议覆盖所有场景 :S/4HANA 标准业务操作用标准 OData API(api.sap.com 有 700+ 服务);ECC 6.0 和自定义逻辑(如 LXE 翻译、源码读取)用 SEGW(Gateway Service Builder)把 RFC 封装成 OData 服务------SEGW 是"万能适配器",任何 ABAP 逻辑都能包成 EntitySet
- HTTP 原生 :JSON 报文 + 标准 REST 动词,Python
requests直接调用,无 JVM 依赖(对比 JCo)、无 XML 手工拼装(对比 SOAP RFC) - 写操作模式统一 :所有写操作走同一套 CSRF token 流程(GET
$metadatafetch → POST/PUT 带回),一处实现,处处复用 - 批量与原子性 :
$batch支持 change set 内多请求原子提交,适合 Agent 批量写回场景 - 版本选择:SEGW 产出 OData V2(ECC + S/4HANA 通用),RAP 产出 V4(仅 S/4HANA)。为保证 ECC 兼容性,本文统一使用 V2 语法
二、实践项目:SAP 多语言自动翻译功能
2.1 功能简介
SAP 系统里有大量开发对象的文本元素需要维护多语言翻译,比如事务码(TCODE)短文本、报表的 Text Symbols 和 Selection Texts、类/函数组的接口描述、DDIC Data Element 描述、Domain 固定值、消息类等。传统方案是事务码 SE63 里逐个对象手工切换语言翻译,面对上百个自定义报表、类、事务码时效率极低。
AI Agent 可以利用 SAP 底层翻译函数,绕过 SE63 图形操作界面,实现程序化批量翻译。
2.2 SAP端开发事项
SAP 翻译函数族只能在 ABAP 内部调用,SE63 本身没有对外 API。按统一 OData出口原则,我们写两个自定义 RFC 封装核心逻辑,再用 SEGW 把它们发布为一个 OData 服务,对外只暴露一个 HTTP 端点。
在SAP ABAP栈中具体实现步骤如下:
- 自动枚举指定对象类型(TRAN/REPT/CLAS/FUGR/DDIC)中缺失目标语言翻译的对象
- 批量读取翻译状态和源语言文本
- 调用本地 LLM 生成目标语言翻译
- 通过 LXE_OBJ_TEXT_PAIR_WRITE 直接写回翻译透明表
- 记录翻译日志,支持纳入传输请求
可翻译对象一览:
| 对象类型 | 说明 | 示例 | 底层存储表 |
|---|---|---|---|
TRAN |
事务码短文本 | VA01 中文描述 → "创建销售订单" | TSTCT(直接查询)+ LXE 翻译内存 |
REPT |
报表程序 Text Symbols / Selection Texts | ZMY_REPORT 的 TEXT-001 / SELECTION-TEXT |
STXH / STXL |
CLAS |
类接口元素描述 | ZCL_MY_CLASS 方法参数说明 | |
FUGR |
函数组接口描述 | ZAI_TRANSLATE 函数参数说明 | |
DDIC |
DDIC 对象 | Data Element 描述 / Domain 固定值 / 表字段标签 | DD04T / DD07T / DD03T |
MSGA |
T100 消息 | Message Class ZAI, 消息号 001 的文本 | T100 |
** ABAP核心翻译函数族**
LXE_OBJ_TEXT_PAIR_READ → 读取单个对象的文本对(源语言 + 当前目标语言)
LXE_OBJ_TEXT_PAIR_READ_TRAN → 批量读取翻译状态(性能优化,预热翻译映射)
LXE_OBJ_TEXT_PAIR_WRITE → 写入翻译(核心,Agent 端 LLM 翻译结果 → SAP)
LXE_OBJ_TRANSLATION_TRANSFER → 将修改添加到传输请求
** 自定义 RFC:批量读取待翻译对象 **
ABAP
FUNCTION ZAI_GET_ABAP_TEXTS.
*"----------------------------------------------------------------------
*" IMPORTING
*" VALUE(IV_OBJ_TYPE) TYPE LXECOBJ-OBJECT " TRAN/REPT/CLAS/FUGR/DDIC
*" VALUE(IV_TARGET_LANG)TYPE SPRAS " 目标语言 E=英文 J=日文
*" VALUE(IV_SOURCE_LANG)TYPE SPRAS DEFAULT '1' " 源语言 1=中文
*" VALUE(IV_MAX_ROWS) TYPE INT4 DEFAULT 100
*" TABLES
*" ET_ABAP_TEXTS STRUCTURE ZAI_ABAP_TEXT
*" EXCEPTIONS
*" NO_DATA_FOUND
*" INVALID_OBJECT_TYPE
*"----------------------------------------------------------------------
TYPES: BEGIN OF ty_obj_entry,
obj_name TYPE trobj_name,
END OF ty_obj_entry.
DATA: lt_obj_names TYPE TABLE OF ty_obj_entry,
lt_map_data TYPE TABLE OF map_t_tstct,
lv_read_only TYPE as4flag.
* 1. 根据对象类型枚举待翻译对象(用 SAP 标准表)
CASE iv_obj_type.
WHEN 'TRAN'. "事务码
SELECT tcode AS obj_name INTO TABLE lt_obj_names
FROM tstc WHERE tcode LIKE 'Z%' UP TO iv_max_rows ROWS.
WHEN 'REPT'. "报表程序
SELECT obj_name INTO TABLE lt_obj_names
FROM tadir WHERE object = 'PROG'
AND obj_name LIKE 'Z%' UP TO iv_max_rows ROWS.
WHEN 'CLAS'. "类
SELECT obj_name INTO TABLE lt_obj_names
FROM tadir WHERE object = 'CLAS'
AND obj_name LIKE 'Z%' UP TO iv_max_rows ROWS.
WHEN 'FUGR'. * 函数组
SELECT obj_name INTO TABLE lt_obj_names
FROM tadir WHERE object = 'FUGR'
AND obj_name LIKE 'Z%' UP TO iv_max_rows ROWS.
WHEN OTHERS.
RAISE invalid_object_type.
ENDCASE.
IF lt_obj_names IS INITIAL.
RAISE no_data_found.
ENDIF.
* 2. 批量读取翻译状态(预热翻译映射表)
lv_read_only = abap_false.
CALL FUNCTION 'LXE_OBJ_TEXT_PAIR_READ_TRAN'
EXPORTING
p_object = iv_obj_type
p_read_only = lv_read_only
TABLES
t_obj_name = lt_obj_names
map_t_tstct = lt_map_data
EXCEPTIONS
cx_lxe_note = 1
OTHERS = 2.
* 3. 筛选出目标语言翻译缺失或为空的记录
LOOP AT lt_map_data INTO DATA(ls_map).
* 源语言文本必须存在,否则跳过
IF ls_map-sourcetext IS INITIAL OR ls_map-sourcetext = space.
CONTINUE.
ENDIF.
* 目标语言翻译缺失时输出
IF ls_map-targettext IS INITIAL OR ls_map-targettext = space.
APPEND VALUE #(
obj_type = iv_obj_type
obj_name = ls_map-obj_name
source_lang = iv_source_lang
source_text = ls_map-sourcetext
target_lang = iv_target_lang
) TO et_abap_texts.
ENDIF.
ENDLOOP.
ENDFUNCTION.
** 自定义 RFC:写入翻译结果 **
ABAP
FUNCTION ZAI_SET_ABAP_TEXT.
*"----------------------------------------------------------------------
*" IMPORTING
*" VALUE(IV_OBJ_TYPE) TYPE LXECOBJ-OBJECT " TRAN/REPT/CLAS/FUGR/DDIC
*" VALUE(IV_OBJ_NAME) TYPE TROBJ_NAME " 对象名,如 VA01 / ZMY_REPORT
*" VALUE(IV_TARGET_LANG)TYPE SPRAS " 目标语言 E=英文 J=日文
*" VALUE(IV_SOURCE_LANG)TYPE SPRAS DEFAULT '1' " 源语言 1=中文
*" VALUE(IV_TRANSLATED) TYPE STRING " LLM 生成的翻译文本
*" EXPORTING
*" VALUE(EV_STATUS) TYPE LXESTATPRC " 返回状态 S=成功 F=失败 W=警告
*" VALUE(EV_MESSAGE) TYPE STRING
*"----------------------------------------------------------------------
DATA: lt_text_pair TYPE TABLE OF lxet_text_pair,
ls_text_pair TYPE lxet_text_pair.
* 构建 LXE_OBJ_TEXT_PAIR_WRITE 所需的 T_TEXT_PAIR 参数
* 注意:LXE 函数需要目标语言的文本对结构,TEXT 字段长度根据对象类型不同
ls_text_pair-text = iv_translated.
APPEND ls_text_pair TO lt_text_pair.
* 调用 SAP 核心翻译写入函数
CALL FUNCTION 'LXE_OBJ_TEXT_PAIR_WRITE'
EXPORTING
p_object = iv_obj_type
p_obj_name = iv_obj_name
p_source = iv_source_lang
p_lang = iv_target_lang
t_text_pair = lt_text_pair
IMPORTING
p_status = ev_status
EXCEPTIONS
OTHERS = 1.
IF sy-subrc <> 0.
ev_status = 'F'.
ev_message = |SY-SUBRC = {sy-subrc}|.
ELSEIF ev_status = 'S'.
ev_message = |翻译成功:{iv_obj_type}/{iv_obj_name} → {iv_target_lang}|.
ELSEIF ev_status = 'W'.
ev_message = |警告:部分成功 {iv_obj_type}/{iv_obj_name} ({ev_status})|.
ELSE.
ev_message = |失败:{iv_obj_type}/{iv_obj_name} 状态={ev_status}|.
ENDIF.
ENDFUNCTION.
** SEGW 封装:把 RFC 发布为OData 服务 **
把 RFC 发布为统一 OData 服务 ZAI_TRANSLATION_SRV
SEGW 项目 ZAI_TRANSLATION_SRV
├── EntityType:AbapText(主键:ObjType + ObjName + TargetLang)
│ ├── ObjType Edm.String TRAN/REPT/CLAS/FUGR/DDIC(Filterable)
│ ├── ObjName Edm.String 对象名,如 VA01(Filterable)
│ ├── SourceLang Edm.String 源语言 1=中文(Filterable)
│ ├── SourceText Edm.String 源文本(Query 返回)
│ ├── TargetLang Edm.String 目标语言 E=英文(Filterable)
│ ├── TranslatedText Edm.String LLM 翻译结果(Create 输入)
│ ├── Status Edm.String P_STATUS:S=成功 W=警告 F=失败
│ └── Message Edm.String 结果消息
└── EntitySet:TextPairs
操作映射:
| OData 操作 | RFC 函数 | 参数映射 |
|---|---|---|
GET /TextPairs?$filter=ObjType eq 'TRAN' and TargetLang eq 'E'&$top=50 |
ZAI_GET_ABAP_TEXTS | ObjType→IV_OBJ_TYPE,TargetLang→IV_TARGET_LANG,SourceLang→IV_SOURCE_LANG,$top→IV_MAX_ROWS |
POST /TextPairs |
ZAI_SET_ABAP_TEXT | 实体属性 → RFC Import 参数(ObjType/ObjName/SourceLang/TargetLang/TranslatedText) |
SEGW 里的三个关键步骤:
- Data Model :定义上面的 EntityType(或 Import from RFC 自动生成),把查询字段勾选 Filterable
- Service Implementation :Query 行映射
ZAI_GET_ABAP_TEXTS,Create 行映射ZAI_SET_ABAP_TEXT(在 Mapping Details 里把实体属性拖到 RFC 参数) - Generate Runtime + 激活 ,再到
/IWFND/MAINT_SERVICE添加服务(System Alias 选LOCAL,嵌入式网关场景)
2.3 AI Agent 平台开发事项
Agent 端只用一个 OData 客户端访问 SAP(统一协议),封装成 MCP Server,再用 LangGraph 编排翻译流程。
协议适配层:SAP OData 客户端
python
import os
import requests
SAP_HOST = "s4hana.example.com"
SAP_PORT = "44300"
SAP_CLIENT = "100"
SAP_USER = "AI_TRANSLATOR"
SAP_PASSWORD = os.getenv("SAP_PASSWORD")
class SapODataClient:
"""SAP OData V2 统一客户端:Query / Read / Create / Function Import
统一处理:
- Basic Auth + sap-client
- 写操作 CSRF token(GET $metadata fetch → POST/PUT 带回)
- $format=json(V2 默认返回 Atom XML,必须显式要 JSON)
"""
def __init__(self, service_path: str):
self.base = f"https://{SAP_HOST}:{SAP_PORT}{service_path}"
self.session = requests.Session()
self.session.auth = (SAP_USER, SAP_PASSWORD)
self.session.headers.update({
"sap-client": SAP_CLIENT,
"Accept": "application/json",
})
self._csrf_token = None
def _fetch_csrf_token(self) -> str:
"""SAP 网关强制:任何写操作前必须先 Fetch CSRF token"""
resp = self.session.get(f"{self.base}/$metadata",
headers={"X-CSRF-Token": "Fetch"})
resp.raise_for_status()
self._csrf_token = resp.headers.get("x-csrf-token", "")
return self._csrf_token
def query(self, entity_set: str, filters: str = None,
top: int = 50, skip: int = None) -> list[dict]:
"""GET EntitySet:$filter / $top / $skip 分页查询"""
params = {"$format": "json"}
if filters:
params["$filter"] = filters
if top:
params["$top"] = str(top)
if skip:
params["$skip"] = str(skip)
resp = self.session.get(f"{self.base}/{entity_set}", params=params)
resp.raise_for_status()
return resp.json().get("d", {}).get("results", [])
def read(self, entity_set: str, key: str) -> dict:
"""GET 单实体。key 如:ObjType='TRAN',ObjName='VA01',TargetLang='E'"""
resp = self.session.get(f"{self.base}/{entity_set}({key})")
resp.raise_for_status()
return resp.json().get("d", {})
def create(self, entity_set: str, payload: dict) -> dict:
"""POST 实体:自动带 CSRF token,支持 Deep Insert 嵌套"""
if self._csrf_token is None:
self._fetch_csrf_token()
resp = self.session.post(
f"{self.base}/{entity_set}",
json=payload,
headers={"X-CSRF-Token": self._csrf_token,
"Content-Type": "application/json"})
resp.raise_for_status()
return resp.json().get("d", {})
def function_import(self, name: str, params: dict) -> list:
"""OData V2 Function Import(带参数的查询操作)"""
params = {**params, "$format": "json"}
resp = self.session.get(f"{self.base}/{name}", params=params)
resp.raise_for_status()
data = resp.json().get("d", [])
if isinstance(data, dict): # 部分网关版本包一层 results
data = data.get("results", [])
return data
MCP Server:SAP ABAP 对象翻译工具封装
python
from fastmcp import FastMCP
mcp = FastMCP("SAP ABAP Translation MCP")
sap = SapODataClient("/sap/opu/odata/sap/ZAI_TRANSLATION_SRV")
@mcp.tool()
def get_abap_objects_for_translation(
obj_type: str = "TRAN",
target_language: str = "E",
source_language: str = "1",
max_rows: int = 50,
) -> list[dict]:
"""从 SAP 获取指定类型中缺失目标语言翻译的 ABAP 开发对象列表。
返回每个对象的名称和源语言文本,供 Agent 批量翻译。
Args:
obj_type: 对象类型,可选值:
TRAN = 事务码短文本
REPT = 报表程序 Text Symbols / Selection Texts
CLAS = 类接口描述
FUGR = 函数组接口描述
DDIC = DDIC Data Element / Domain 描述
target_language: 目标语言代码,E=英文, J=日文, F=法文, G=德文
source_language: 源语言代码,1=中文, D=德文, J=日文
max_rows: 最多返回的对象数量
"""
filt = (f"ObjType eq '{obj_type}' and TargetLang eq '{target_language}' "
f"and SourceLang eq '{source_language}'")
return sap.query("TextPairs", filters=filt, top=max_rows)
@mcp.tool()
def set_abap_text_translation(
obj_type: str,
obj_name: str,
target_language: str,
translated_text: str,
source_language: str = "1",
) -> str:
"""将 LLM 生成的翻译写回 SAP 系统。
OData Create → 后端 RFC → LXE_OBJ_TEXT_PAIR_WRITE,效果等价于 SE63 手工翻译。
Args:
obj_type: 对象类型(TRAN/REPT/CLAS/FUGR/DDIC)
obj_name: 对象名称,如 VA01(事务码)、ZMY_REPORT(报表)
target_language: 目标语言代码
translated_text: LLM 翻译生成的目标语言文本
source_language: 源语言代码,默认 1=中文
"""
result = sap.create("TextPairs", {
"ObjType": obj_type,
"ObjName": obj_name,
"SourceLang": source_language,
"TargetLang": target_language,
"TranslatedText": translated_text,
})
status = result.get("Status", "F")
message = result.get("Message", "")
if status == "S":
return f"{obj_type}/{obj_name} → {target_language} 翻译成功"
elif status == "W":
return f"{obj_type}/{obj_name} → {target_language} 警告: {message}"
else:
return f"{obj_type}/{obj_name} → {target_language} 失败: {message}"
@mcp.tool()
def get_abap_text_pair(obj_type: str, obj_name: str, target_language: str) -> dict:
"""读取单个 ABAP 对象的翻译状态:源语言文本 + 当前目标语言文本。
用于翻译前的"预读"和翻译后的"回读校验"。
Args:
obj_type: 对象类型(TRAN/REPT/CLAS/FUGR/DDIC)
obj_name: 对象名称
target_language: 目标语言代码
"""
key = (f"ObjType='{obj_type}',ObjName='{obj_name}',"
f"TargetLang='{target_language}'")
return sap.read("TextPairs", key)
if __name__ == "__main__":
mcp.run()
LangGraph Agent 执行流
#mermaid-svg-eVSaX3dCGrQZ4lx5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .error-icon{fill:#552222;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .marker.cross{stroke:#333333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eVSaX3dCGrQZ4lx5 p{margin:0;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .cluster-label text{fill:#333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .cluster-label span{color:#333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .cluster-label span p{background-color:transparent;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .label text,#mermaid-svg-eVSaX3dCGrQZ4lx5 span{fill:#333;color:#333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .node rect,#mermaid-svg-eVSaX3dCGrQZ4lx5 .node circle,#mermaid-svg-eVSaX3dCGrQZ4lx5 .node ellipse,#mermaid-svg-eVSaX3dCGrQZ4lx5 .node polygon,#mermaid-svg-eVSaX3dCGrQZ4lx5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .rough-node .label text,#mermaid-svg-eVSaX3dCGrQZ4lx5 .node .label text,#mermaid-svg-eVSaX3dCGrQZ4lx5 .image-shape .label,#mermaid-svg-eVSaX3dCGrQZ4lx5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .rough-node .label,#mermaid-svg-eVSaX3dCGrQZ4lx5 .node .label,#mermaid-svg-eVSaX3dCGrQZ4lx5 .image-shape .label,#mermaid-svg-eVSaX3dCGrQZ4lx5 .icon-shape .label{text-align:center;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .node.clickable{cursor:pointer;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .arrowheadPath{fill:#333333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eVSaX3dCGrQZ4lx5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eVSaX3dCGrQZ4lx5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eVSaX3dCGrQZ4lx5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .cluster text{fill:#333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .cluster span{color:#333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eVSaX3dCGrQZ4lx5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .icon-shape,#mermaid-svg-eVSaX3dCGrQZ4lx5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .icon-shape p,#mermaid-svg-eVSaX3dCGrQZ4lx5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .icon-shape .label rect,#mermaid-svg-eVSaX3dCGrQZ4lx5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eVSaX3dCGrQZ4lx5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eVSaX3dCGrQZ4lx5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eVSaX3dCGrQZ4lx5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} START
get_abap_objects_for_translation
OData Query 枚举待翻译对象
LLM 批量翻译
生成目标语言文本
set_abap_text_translation
OData Create → LXE 写回
P_STATUS 状态校验
S=成功 W=警告 F=失败
END
Agent 的核心翻译逻辑
python
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator
class TranslationState(TypedDict):
obj_type: str
target_lang: str
source_lang: str
objects: list[dict] # 待翻译对象列表
translations: Annotated[list, operator.add] # 翻译结果累积
errors: list[str]
def fetch_objects(state: TranslationState) -> dict:
"""从 SAP 获取指定类型中缺失目标语言翻译的对象"""
result = mcp_client.call_tool("get_abap_objects_for_translation", {
"obj_type": state["obj_type"],
"target_language": state["target_lang"],
"source_language": state["source_lang"],
"max_rows": 20,
})
return {"objects": result}
def translate_and_write(state: TranslationState) -> dict:
"""LLM 翻译 + 写回 SAP(每次处理一个对象,循环执行)"""
done = len(state["translations"])
if done >= len(state["objects"]):
return {"translations": []}
obj = state["objects"][done]
obj_type = obj["OBJ_TYPE"]
obj_name = obj["OBJ_NAME"]
source_text = obj.get("SOURCE_TEXT", "")
if not source_text or source_text == " ":
return {"translations": [{"obj": obj_name, "skipped": True,
"reason": "源文本为空"}]}
# 本地 LLM 翻译
translated = llm.invoke(
f"Translate this SAP ABAP object text to English. "
f"Keep it concise and consistent with SAP terminology:\n"
f"Source (Chinese): {source_text}"
).content.strip()
# 写回 SAP(OData Create → 后端 RFC → LXE_OBJ_TEXT_PAIR_WRITE)
write_result = mcp_client.call_tool("set_abap_text_translation", {
"obj_type": obj_type,
"obj_name": obj_name,
"target_language": state["target_lang"],
"source_language": state["source_lang"],
"translated_text": translated,
})
return {"translations": [{
"obj": obj_name,
"source": source_text,
"translated": translated,
"sap_result": write_result,
}]}
# 图编排
graph = StateGraph(TranslationState)
graph.add_node("fetch", fetch_objects)
graph.add_node("translate", translate_and_write)
graph.add_edge(START, "fetch")
graph.add_edge("fetch", "translate")
graph.add_conditional_edges("translate",
lambda s: END if len(s["translations"]) >= len(s["objects"]) else "translate",
{"translate": "translate", END: END})
agent = graph.compile()
# 启动:翻译事务码(TRAN)从中文(1)到英文(E)
result = agent.invoke({
"obj_type": "TRAN",
"target_lang": "E",
"source_lang": "1",
"objects": [],
"translations": [],
"errors": [],
})