[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-53":3},{"id":4,"title":5,"title_en":6,"abstract":7,"abstract_en":8,"content":9,"content_en":10,"category":11,"banner_id":12,"banner_path":13,"tags":14,"is_recommend":17,"prev_article":18,"next_article":22,"created_at":26},53,"GopherGraph v1.1.2 升级实战：干掉高并发下的“错误吞没”与死循环误判","GopherGraph v1.1.2: A Pragmatic Journey of Concurrency Hardening and FSM Edge Cases","前段时间，我设计并开源了一个轻量级、高性能、基于 Go 泛型实现的 Multi-Agent","Some time ago, I designed and open-sourced **Gophe","前段时间，我设计并开源了一个轻量级、高性能、基于 Go 泛型实现的 Multi-Agent 编排引擎 —— **GopherGraph**。它的初衷是把 Python 生态中 `LangGraph` 那种支持图循环、共享状态（State）以及人机协同（Human-in-the-Loop）的能力移植到 Go 生态中，并利用 Go 的原生并发和强类型泛型提供极致的性能。\n\n在发布了修复了并发数据竞争和路径穿越漏洞的 v1.1.0 版本后，我将引擎投入到了更复杂的生产级高并发场景中压测。然而，在面对复杂的动态路由和极限的并发分流（Fan-Out\u002FFan-In）时，一些隐藏得更深的边缘情况（Edge Cases）还是暴露了出来。\n\n为了让 GopherGraph 达到真正的“工业级健壮性”，我决定再次进行一次深水区的代码硬化，并发布了 **v1.1.2 版本**。\n\n在这篇文章中，我将继续以第一人称的视角，分享我在这段优化演进之路中踩到的新坑，以及如何优雅地消灭它们。\n\n---\n\n## 缺陷一：高并发分流下的「错误吞噬」陷阱 (P0级致命伤)\n\n### 发现问题\nGopherGraph 原生支持多个 Agent 节点的并发执行（通过 `AddParallelEdges` 实现）。为了保障算力不被浪费，我们基于 `context.WithCancel` 设计了短路取消机制：一旦某个并发分支报错，引擎会立刻取消其他正在运行的兄弟分支。\n\n然而，在高频的并发测试中，我发现了一个极其隐蔽的 Race Condition（数据竞争错误）。原本的并发结果收集代码大致如下：\n\n```go\n\u002F\u002F 旧版错误收集器（简化版）\nresultCh := make(chan result, len(targets))\nfor i, target := range targets {\n    go func(idx int, node string) {\n        state, err := cg.executeSingle(ctx, node, s)\n        resultCh \u003C- result{idx: idx, state: state, err: err}\n    }(i, target)\n}\n\nfor i := 0; i \u003C len(targets); i++ {\n    r := \u003C-resultCh\n    if r.err != nil {\n        \u002F\u002F 试图返回第一个非取消的真实错误，过滤掉由于被取消产生的次生错误\n        if !errors.Is(r.err, context.Canceled) {\n            return nil, r.err \n        }\n    }\n}\n```\n\n**Bug 触发链路：**\n1. 并发分支 A 发生了真正的业务错误（例如 LLM 接口限流），返回了相应的 Error，并触发了 `cancel()` 广播。\n2. 并发分支 B 接收到取消信号，提前退出，并向 `resultCh` 写入了 `context.Canceled` 错误。\n3. 由于 Go Runtime 协程调度的随机性，分支 B 的退出和写入速度可能比分支 A 的异常处理更快。\n4. 结果是：通道里首先被读取到的是 `context.Canceled`。旧版代码在读取到它后仅仅是简单跳过，而此时循环步数已经消耗，这导致真实的限流错误在随后的通道排空里被忽略，或者直接向调用方返回了 `nil` 错误。**真正的错误被静默吞掉了！**\n\n### 解决思路：构建确定性的协程同步屏障\n为了保证异常传播的百分之百确定性，我们必须斩断一切基于 select\u002FChannel 读取时序的偶然性。解决办法是：强制引入同步屏障，等所有并发协程完全退出并关闭通道后，再顺序排空并精准过滤：\n\n```diff\n \u002F\u002F engine.go\n func (cg *CompiledGraph[S]) runParallelBranches(ctx context.Context, targets []string, state S, cloner func(S) S) ([]S, error) {\n     ctx, cancel := context.WithCancel(ctx)\n     defer cancel()\n \n     var wg sync.WaitGroup\n     resultCh := make(chan result, len(targets))\n \n     for i, target := range targets {\n         wg.Add(1)\n         \u002F\u002F 拷贝状态，隔离并发内存...\n         go func(idx int, node string, s S) {\n             defer wg.Done()\n             resState, err := cg.executeSingleNode(ctx, node, s)\n             if err != nil {\n                 cancel() \u002F\u002F 广播通知兄弟分支\n                 resultCh \u003C- result{idx: idx, err: err}\n                 return\n             }\n             resultCh \u003C- result{idx: idx, state: resState}\n         }(i, target, stateCopy)\n     }\n \n+    \u002F\u002F 等待所有协程执行完毕，关闭通道\n+    wg.Wait()\n+    close(resultCh)\n \n     branches := make([]S, len(targets))\n+    var firstRealErr error\n-    for r := range resultCh {\n-        if r.err != nil {\n-            if !errors.Is(r.err, context.Canceled) {\n-                return nil, r.err\n-            }\n-        } else {\n-            branches[r.idx] = r.state\n-        }\n-    }\n+    for r := range resultCh {\n+        if r.err != nil {\n+            \u002F\u002F 只有当尚未捕获真实错误时，才捕获第一个真正导致失败的根因错误\n+            if !errors.Is(r.err, context.Canceled) && firstRealErr == nil {\n+                firstRealErr = r.err\n+            }\n+        } else {\n+            branches[r.idx] = r.state\n+        }\n+    }\n+\n+    if firstRealErr != nil {\n+        return nil, firstRealErr\n+\t}\n     return branches, nil\n }\n```\n\n现在，GopherGraph 保证了任何并发分支下的真实根因错误都能稳定向上传递。\n\n---\n\n## 缺陷二：有环状态机下的「步数熔断」Off-by-One 边界漏洞 (P1级)\n\n### 发现问题\n在包含循环的工作流中，大模型有可能会因为死脑筋而陷入无限纠错循环。为了兜底，GopherGraph 通过 `Engine.WithMaxSteps(N)` 限制了执行的最大节点步数。\n\n原先的校验逻辑被简单放在了调度循环的最前端：\n\n```go\n\u002F\u002F 旧版逻辑流程\nfor {\n    if opts.maxSteps > 0 {\n        if stepCount >= opts.maxSteps {\n            return thread, fmt.Errorf(\"max steps (%d) exceeded\", opts.maxSteps)\n        }\n        stepCount++\n    }\n    \n    currentNodeName := thread.NextNode\n    if currentNodeName == \"\" {\n        thread.IsFinished = true\n        return thread, nil\n    }\n    \u002F\u002F 执行节点...\n}\n```\n\n**边界误判链路：**\n假设我设置了最大步数 `maxSteps = 1`，图里只有一个起始节点 `A`，且执行完后整个图应该结束：\n1. 第一轮循环：`stepCount = 0`，校验 `0 >= 1` 不通过，`stepCount` 自增为 `1`。执行节点 `A`，更新 `NextNode` 为 `\"\"`。\n2. 第二轮循环：此时 `stepCount = 1`，一进来触发 `1 >= 1` 的熔断校验，直接抛出了“步数超限”错误。\n3. 实际上，图此时已经正常走完了 1 步并本应宣告完结。在判定“是否到终点”之前去扣除熔断步数，导致了对正常结束流的误杀（Off-by-One 错误）。\n\n### 解决思路：时序倒置，完结优先\n我们重新调整了状态机内部循环的控制流时序，优先检查图是否已进入终止状态（Sink State），而后再对即将执行的新一步做熔断校验。\n\n```go\n\u002F\u002F engine.go (v1.1.2)\nfor {\n    \u002F\u002F 优先检测外部取消信号\n    select {\n    case \u003C-ctx.Done():\n        return thread, ctx.Err()\n    default:\n    }\n\n    currentNodeName := thread.NextNode\n    \u002F\u002F 1. 优先判定工作流是否已完美运行结束\n    if currentNodeName == \"\" {\n        thread.IsFinished = true\n        return thread, nil\n    }\n\n    \u002F\u002F 2. 在确认需要执行下一个节点时，进行熔断计数校验\n    if opts.maxSteps > 0 {\n        if stepCount >= opts.maxSteps {\n            return thread, fmt.Errorf(\n                \"engine halt: max steps (%d) exceeded, possible infinite loop in graph\",\n                opts.maxSteps,\n            )\n        }\n        stepCount++\n    }\n    \u002F\u002F 3. 执行节点...\n}\n```\n\n---\n\n## 缺陷三：构建期节点与边注册的「静默覆盖」逻辑漏洞 (P1级)\n\n### 发现问题\n在图的配置构建阶段，GopherGraph 采用流畅的链式 API：\n\n```go\ng := GopherGraph.NewGraph[MyState]()\ng.AddNode(\"agent\", runAgent)\ng.AddEdge(\"agent\", \"tool\")\n```\n\n旧版本里，如果开发者因为手滑或代码合并冲突，对同一个节点名重复注册，或者对同一个出边多次声明：\n\n```go\ng.AddNode(\"agent\", runAgent1)\ng.AddNode(\"agent\", runAgent2) \u002F\u002F 会悄无声息地直接覆盖掉 runAgent1\n```\n\n由于底层完全是裸 map 赋值，这不仅造成了配置被静默覆盖（Silent Overwrite），还导致最终图运行结果完全不符合预期，排查起来非常折磨。\n\n### 解决思路：编译期错误累加机制 (Error Aggregator)\n我们重构了 `graph.go`，在构建 API 时，如果检测到冲突（如重复节点、重复静态边、重复并发边或条件路由），不抛出 panic 阻断链式调用，而是通过内部错误列表收集起来，最终在 `Compile()` 时使用标准库的 `errors.Join` 一次性以极其清晰的提示抛给开发者：\n\n```go\n\u002F\u002F graph.go\nfunc (g *Graph[S]) AddNode(name string, fn NodeFn[S]) *Graph[S] {\n    if _, exists := g.nodes[name]; exists {\n        g.errs = append(g.errs, fmt.Errorf(\"node %q is already registered\", name))\n        return g\n    }\n    g.nodes[name] = fn\n    return g\n}\n\nfunc (g *Graph[S]) Compile() (*CompiledGraph[S], error) {\n    if len(g.errs) > 0 {\n        return nil, fmt.Errorf(\"graph syntax error: %w\", errors.Join(g.errs...))\n    }\n    \u002F\u002F ... 执行图的静态连通性校验并编译\n}\n```\n\n---\n\n## 缺陷四：API 设计层面的「校验冗余」精简 (P1级)\n\n### 发现问题\nGopherGraph 支持人机协同（HITL），通过恢复运行被挂起的线程。为了对外提供友好的拦截，我们需要提供错误校验以防多次 `Resume` 或对未暂停线程进行操作。\n\n然而，在审计代码时我发现，这个校验逻辑在 `CompiledGraph.Resume` 和它的上层包装器 `Engine.Resume` 里一模一样地写了两遍：\n\n```go\n\u002F\u002F 冗余逻辑：在 CompiledGraph 和 Engine 中各写了一套\nif !thread.IsPaused {\n    return nil, ErrNotPaused\n}\nif thread.IsFinished {\n    return nil, ErrAlreadyFinished\n}\n```\n\n不仅代码显得臃肿，以后一旦有新的校验机制加入，极易漏改其中一方导致 API 的不一致。\n\n### 解决思路：提炼泛型验证器 (Generic Validator)\n我在 `engine.go` 内部提炼出了一个无副作用的包级私有函数 `validateResume`，两者统一复用，彻底干掉了冗余代码：\n\n```go\n\u002F\u002F engine.go\nfunc validateResume[S any](thread *Thread[S]) error {\n\tif !thread.IsPaused {\n\t\treturn ErrNotPaused\n\t}\n\tif thread.IsFinished {\n\t\treturn ErrAlreadyFinished\n\t}\n\treturn nil\n}\n```\n\n---\n\n## 总结：精进即修行\n\n通过这次对 GopherGraph v1.1.2 版本的深度硬化，项目完美扫清了并发与边界的全部痛点，在保持“原生、高性能、零第三方依赖”的基础上，大幅提升了系统的健壮性。\n\n在编写多智能体编排引擎这样的底层基础设施时，对异常的敬畏、对并发安全的严苛以及对 API 边界的打磨，决定了框架所能承载的业务高度。希望这次踩坑与改动的分享能帮到正在开发类似中间件的你。\n\n👉 **项目地址**：[github.com\u002Funclesam-ly\u002FGopherGraph](https:\u002F\u002Fgithub.com\u002Funclesam-ly\u002FGopherGraph)  \n如果你觉得这次的安全优化有收获，欢迎来给项目点个 Star！有任何想法也欢迎在 Issue 区交流。\n","Some time ago, I designed and open-sourced **GopherGraph**, a lightweight, high-performance, and type-safe Multi-Agent workflow orchestration engine built in Go. Inspired by Python's `LangGraph`, GopherGraph brings compile-time strictly-typed state tracking to Go's generic ecosystem, leveraging goroutines and channels to achieve microsecond-level local state transitions.\n\nWhile the previous v1.1.0 release successfully resolved crucial race conditions via explicit state cloning and hardened storage against path traversal vulnerabilities, deploying GopherGraph in complex, high-concurrency environments exposed a few subtle yet critical edge cases.\n\nIn this article, I will share the engineering insights behind the **v1.1.2 release**, detailing how we addressed concurrent error masking, refined FSM step-limit execution, eliminated building-phase silent overwrites, and resolved API design redundancies.\n\n---\n\n## 1. Concurrency Hardening: Resolving the \"Canceled Sibling\" Error Masking (P0)\n\n### 1.1 The Discovery\nGopherGraph supports concurrent branch execution (Fan-Out\u002FFan-In) via `AddParallelEdges`. When executing parallel agent steps, we spin up independent goroutines for each target. To conserve compute and API quota, the engine utilizes `context.WithCancel` so that if one branch fails, it immediately triggers the cancellation of all sibling branches.\n\nHowever, during strict load testing, we discovered a subtle race condition in our error-harvesting loop. Here was our original concurrent aggregator:\n\n```go\n\u002F\u002F Old version error collector (simplified)\nresultCh := make(chan result, len(targets))\nfor i, target := range targets {\n    go func(idx int, node string) {\n        state, err := cg.executeSingle(ctx, node, s)\n        resultCh \u003C- result{idx: idx, state: state, err: err}\n    }(i, target)\n}\n\nfor i := 0; i \u003C len(targets); i++ {\n    r := \u003C-resultCh\n    if r.err != nil {\n        \u002F\u002F Attempt to return the first authentic error, ignoring subsequent cancellations\n        if !errors.Is(r.err, context.Canceled) {\n            return nil, r.err \n        }\n    }\n}\n```\n\n#### The Bug Pattern:\n1. Sibling Goroutine A encounters a real business logic error (e.g., LLM API rate limit) and triggers `cancel()`.\n2. Sibling Goroutine B notices the cancellation, stops executing, and pushes `context.Canceled` into `resultCh`.\n3. Due to Go runtime scheduling unpredictability, the `context.Canceled` result from Goroutine B might arrive in `resultCh` before Goroutine A's error is processed.\n4. Because the loop encountered `context.Canceled` first and simply skipped it, the main thread exited the channel read early or returned `nil` errors entirely, **silently masking the root cause error**.\n\n### 1.2 The Fix: Structured Synchronization Barriers\nTo guarantee error propagation determinism, we refactored the pipeline to enforce a strict synchronization barrier. We must block until *all* goroutines yield their lifecycles, and then sequentially inspect the channel:\n\n```diff\n \u002F\u002F engine.go\n func (cg *CompiledGraph[S]) runParallelBranches(ctx context.Context, targets []string, state S, cloner func(S) S) ([]S, error) {\n     ctx, cancel := context.WithCancel(ctx)\n     defer cancel()\n \n     var wg sync.WaitGroup\n     resultCh := make(chan result, len(targets))\n \n     for i, target := range targets {\n         wg.Add(1)\n         \u002F\u002F Clone state to isolate memory space...\n         go func(idx int, node string, s S) {\n             defer wg.Done()\n             resState, err := cg.executeSingleNode(ctx, node, s)\n             if err != nil {\n                 cancel() \u002F\u002F Notify sibling branches\n                 resultCh \u003C- result{idx: idx, err: err}\n                 return\n             }\n             resultCh \u003C- result{idx: idx, state: resState}\n         }(i, target, stateCopy)\n     }\n \n+    \u002F\u002F Wait for all goroutines to finish before closing the channel\n+    wg.Wait()\n+    close(resultCh)\n \n     branches := make([]S, len(targets))\n+    var firstRealErr error\n-    for r := range resultCh {\n-        if r.err != nil {\n-            if !errors.Is(r.err, context.Canceled) {\n-                return nil, r.err\n-            }\n-        } else {\n-            branches[r.idx] = r.state\n-        }\n-    }\n+    for r := range resultCh {\n+        if r.err != nil {\n+            \u002F\u002F Capture the first authentic error and ignore secondary cancellations\n+            if !errors.Is(r.err, context.Canceled) && firstRealErr == nil {\n+                firstRealErr = r.err\n+            }\n+        } else {\n+            branches[r.idx] = r.state\n+        }\n+    }\n+\n+    if firstRealErr != nil {\n+        return nil, firstRealErr\n+\t}\n     return branches, nil\n }\n```\n\nNow, GopherGraph guarantees that the true failure root cause is bubbled up, eliminating \"phantom successes\" in failing parallel workflows.\n\n---\n\n## 2. FSM Execution Refinement: MaxSteps Off-by-One Boundary Correctness (P1)\n\n### 2.1 The Discovery\nGopherGraph provides a circuit breaker via `Engine.WithMaxSteps(N)` to prevent infinite routing loops caused by unpredictable LLM behaviors. If the workflow executes more than `N` steps without halting, the engine halts with an error.\n\nPreviously, we checked the step count at the very beginning of the state-machine loop:\n\n```go\n\u002F\u002F Old logic flow\nfor {\n    if opts.maxSteps > 0 {\n        if stepCount >= opts.maxSteps {\n            return thread, fmt.Errorf(\"max steps (%d) exceeded\", opts.maxSteps)\n        }\n        stepCount++\n    }\n    \n    currentNodeName := thread.NextNode\n    if currentNodeName == \"\" {\n        thread.IsFinished = true\n        return thread, nil\n    }\n    \u002F\u002F Execute node...\n}\n```\n\n#### The Boundary Failure:\nIf `maxSteps` was set to `1` for a single-node workflow (`StartNode -> End`):\n1. Loop 1: `stepCount = 0`, validation `0 >= 1` is false. `stepCount` becomes `1`. StartNode executes and sets `NextNode` to `\"\"`.\n2. Loop 2: `stepCount = 1`, validation `1 >= 1` triggers, throwing a \"Max steps exceeded\" error.\n3. In reality, the graph successfully completed on step 1. Evaluating the step limit *before* checking if the graph has naturally terminated created a false positive \"infinite loop\" warning.\n\n### 2.2 The Fix: Shift Validation Downstream\nWe shifted the `maxSteps` validation sequence below the sink-state (`NextNode == \"\"`) evaluation. This aligns GopherGraph's step limit with a clean, intuitive rule: *a step limit of N allows exactly N node executions*.\n\n```go\n\u002F\u002F engine.go (v1.1.2)\nfor {\n    \u002F\u002F 1. Evaluate Context Cancellation\n    select {\n    case \u003C-ctx.Done():\n        return thread, ctx.Err()\n    default:\n    }\n\n    currentNodeName := thread.NextNode\n    \u002F\u002F 2. Check if the graph has already finished\n    if currentNodeName == \"\" {\n        thread.IsFinished = true\n        return thread, nil\n    }\n\n    \u002F\u002F 3. Perform circuit breaker step check\n    if opts.maxSteps > 0 {\n        if stepCount >= opts.maxSteps {\n            return thread, fmt.Errorf(\n                \"engine halt: max steps (%d) exceeded, possible infinite loop in graph\",\n                opts.maxSteps,\n            )\n        }\n        stepCount++\n    }\n    \u002F\u002F 4. Continue executing the node...\n}\n```\n\n---\n\n## 3. Strict Compile-Time Validation: Eradicating Silent Overwrites (P1)\n\n### 3.1 The Discovery\nGopherGraph utilizes a fluent builder API to declare nodes and transitions:\n\n```go\ng := GopherGraph.NewGraph[MyState]()\ng.AddNode(\"agent\", runAgent)\ng.AddEdge(\"agent\", \"tool\")\n```\n\nIn earlier versions, if a developer hand-wrote duplicate nodes or conflicting transitions:\n\n```go\ng.AddNode(\"agent\", firstAgent)\ng.AddNode(\"agent\", secondAgent) \u002F\u002F silently overwrote firstAgent!\n```\n\nThese mistakes were silently swallowed due to basic map assignment in the builder phase, making layout configuration bugs extremely hard to identify.\n\n### 3.2 The Fix: The Error Collector Pattern\nWe modified `graph.go` to collect registration conflicts in a slice of errors during the build API calls. We then validate and bubble up all conflicts collectively when `Compile()` is called, providing a clear list of layout flaws:\n\n```go\n\u002F\u002F graph.go\nfunc (g *Graph[S]) AddNode(name string, fn NodeFn[S]) *Graph[S] {\n    if _, exists := g.nodes[name]; exists {\n        g.errs = append(g.errs, fmt.Errorf(\"node %q is already registered\", name))\n        return g\n    }\n    g.nodes[name] = fn\n    return g\n}\n\nfunc (g *Graph[S]) Compile() (*CompiledGraph[S], error) {\n    if len(g.errs) > 0 {\n        return nil, fmt.Errorf(\"graph syntax error: %w\", errors.Join(g.errs...))\n    }\n    \u002F\u002F ... check edge boundaries and compile\n}\n```\n\n---\n\n## 4. API Redundancy Cleanup: Consolidated Resume Validation (P1)\n\n### 4.1 The Discovery\nGopherGraph supports human-in-the-loop state injection via `.Resume(ctx, thread, modifiedState)`. This method exists on both the low-level `CompiledGraph` and the high-level `Engine` wrapper.\n\nPreviously, both structs duplicated the validation logic checking if a thread is indeed paused and not yet finished:\n\n```go\n\u002F\u002F Duplicated in both CompiledGraph.Resume and Engine.Resume\nif !thread.IsPaused {\n    return nil, ErrNotPaused\n}\nif thread.IsFinished {\n    return nil, ErrAlreadyFinished\n}\n```\n\nThis redundancy was error-prone and increased cognitive load when maintaining internal thread state transitions.\n\n### 4.2 The Fix: Generic Helper Function\nWe extracted these checks into a clean, unexported generic helper function:\n\n```go\n\u002F\u002F engine.go\nfunc validateResume[S any](thread *Thread[S]) error {\n\tif !thread.IsPaused {\n\t\treturn ErrNotPaused\n\t}\n\tif thread.IsFinished {\n\t\treturn ErrAlreadyFinished\n\t}\n\treturn nil\n}\n```\n\nBoth `CompiledGraph` and `Engine` now delegate their validation phase to `validateResume`, DRYing up the codebase.\n\n---\n\n## 5. Summary: Continuous Polish in Go Middleware\n\nWith the release of v1.1.2, GopherGraph reinforces its core mission: **to provide a zero-dependency, ultra-lightweight, and strictly-typed Multi-Agent orchestrator in Go**. \n\nBy resolving parallel error race conditions, clarifying FSM boundaries, and enforcing compile-time validation, the engine has matured from a prototype to a production-hardened orchestrator.\n\nExplore the source and check out the new features:\n👉 **GopherGraph on GitHub**: [github.com\u002Funclesam-ly\u002FGopherGraph](https:\u002F\u002Fgithub.com\u002Funclesam-ly\u002FGopherGraph)\n\nIf you find this update useful, feel free to star the repo or open an issue to share your thoughts!\n","agent",28,"https:\u002F\u002Fblog4-1316398321.cos.ap-nanjing.myqcloud.com\u002Fblog5\u002F20260621004623__佐佐木-动漫.png",[15,16],"Agent","AI",false,{"id":19,"title":20,"title_en":21},52,"在 Go 里接入多个大模型厂商：一个兼容 OpenAI 协议的通用客户端设计","Building a Universal Multi-Provider LLM Client in Golang (OpenAI-Compatible)",{"id":23,"title":24,"title_en":25},54,"# 构建高可维护的 Go Web 脚手架：从反射型 ORM 到图引擎 Ent 的架构演进","#Building a highly maintainable Go Web scaffold: The architectural evolution from reflective ORM to graph engine Ent","2026-07-04T21:35:12.47195+08:00"]