You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dubbo.apache.org by al...@apache.org on 2026/05/24 06:33:49 UTC

(dubbo-go-pixiu) branch develop updated: fix(endpoint): stabilize endpoint.ID across restarts (nacos + static-config paths) (#930)

This is an automated email from the ASF dual-hosted git repository.

AlexStocks pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git


The following commit(s) were added to refs/heads/develop by this push:
     new 91688317 fix(endpoint): stabilize endpoint.ID across restarts (nacos + static-config paths) (#930)
91688317 is described below

commit 91688317f74306b5117c2120fc8146def0bc4eea
Author: 承潜 <29...@qq.com>
AuthorDate: Sun May 24 14:33:43 2026 +0800

    fix(endpoint): stabilize endpoint.ID across restarts (nacos + static-config paths) (#930)
    
    * model: add GeneratedEndpointID helper for deterministic endpoint identity
    
    Returns a stable "generated-<sha8>" identifier derived from cluster name,
    endpoint address, and (when present) LLM provider + API key. Endpoints
    that differ only by credential do not collide, and the same endpoint
    across restarts hashes to the same ID.
    
    Used by the Nacos LLM registry to drop random UUID fallback identifiers.
    
    * fix(adapter/nacos): deterministic endpoint identity and address baseline
    
    generateEndpoint previously generated a fresh UUID for every Nacos
    instance that did not carry an explicit metadata["id"]. Process restarts
    and re-subscriptions therefore broke any external mapping keyed on
    endpoint.ID (metrics labels, log correlation, runtime health tracking).
    
    Replace the UUID fallback with a three-step lookup:
      1. metadata["id"] (trimmed)
      2. instance.InstanceId (trimmed)
      3. model.GeneratedEndpointID(cluster, endpoint) — stable hash of
         cluster, address, and LLM credential material
    
    Two related fixes ride along because they share the same function:
      - Address now seeds from instance.Ip / instance.Port before metadata
        overrides, so instances without metadata.ip/port still get a usable
        upstream address.
      - When metadata["port"] fails to parse, the previous code overwrote
        Address.Port with 0; now the parsed value only applies on success.
    
    Endpoint.ID becomes upgrade-visible: existing deployments will see new
    IDs after upgrade. Operators relying on endpoint.ID for dashboards or
    log correlation should rebuild their indexes.
    
    * test(adapter/nacos): cover stable ID, rename, InstanceId fallback paths
    
    - Invalid port now expects fallback to instance.Port instead of zero,
      matching the parse-failure fix.
    - generateEndpoint prefers instance.InstanceId when metadata id is absent.
    - generateEndpoint hashes deterministically when both metadata id and
      instance id are absent; rotating an API key changes the generated ID,
      the raw key never appears in it.
    - subscribeCallback emits stable IDs across renames and re-subscriptions.
    - subscribeCallback keeps endpoints with identical credentials but
      different Nacos InstanceIds distinct.
    
    * model: rename GeneratedEndpointID -> GenerateEndpointID and expand docs
    
    Rename uses the standard Go verb form. The godoc now states three
    non-obvious design choices so future readers don't have to re-derive
    them:
    
    - The 64-bit (8-byte) truncation rationale and the per-cluster scale
      at which it is safe.
    - That clusterName is part of the hash material so endpoints from
      different clusters never alias, and how Nacos callers supply it.
    - That the LLM API key is included on purpose, with the SHA-256
      one-way guarantee that prevents the raw key from leaking into IDs.
    
    Adds two helper test cases that nail the contract:
    - Different cluster names produce different IDs for the same endpoint.
    - Different addresses (host or port) produce different IDs.
    
    * server: replace random-UUID endpoint identity with GenerateEndpointID
    
    assembleClusterEndpoints used to assign a random UUID to any endpoint
    with an empty ID, mirroring the now-removed Nacos UUID fallback and
    breaking the same downstream contracts (metrics keyed on endpoint.ID,
    PickNextEndpoint cursors, DeleteEndpoint lookups). Switch to the
    deterministic helper so endpoint identity is stable across process
    restarts regardless of registry source.
    
    Two static endpoints sharing (cluster_name, address, provider, api_key)
    now collapse to a single identifier. They were already indistinguishable
    to the picker, so the previous random IDs offered no real separation;
    operators who need distinct entries must set an explicit "id:".
    
    hashicorp/go-uuid is no longer imported anywhere in pkg/, so it drops
    from the direct dependency list (still transitively pulled in).
    
    * server: warn on duplicate generated endpoint IDs and harden contract
    
    Two follow-ups to the random-UUID -> deterministic-ID migration:
    
    assembleClusterEndpoints now logs a warning when two endpoints inside
    the same cluster collapse to the same generated ID. Previously this was
    silent: operators would discover the missing entry only via dashboards
    counting fewer endpoints than the config declares. The warning names
    both slice positions and the shared ID so the fix (set explicit `id:`)
    is mechanical.
    
    endpointIDMaterial gets a contract comment stating that it MUST NOT
    depend on endpoint.Name. Two call sites rely on this implicitly:
    ClusterStore.assembleClusterEndpoints derives the ID before assigning
    a default Name, and TestGenerateEndpointIDIgnoresEndpointName asserts
    rename invariance. Documenting the contract makes the constraint
    visible to anyone tempted to extend the hash material.
    
    The godoc paragraph on truncation now reads as a neutral statement
    rather than first-person ("we"), since this is library code in an OSS
    project.
    
    Adds TestClusterManager_AssembleEndpointsCollapseDuplicateGenerated to
    lock the documented collapse behavior.
    
    * adapter/nacos: unify nacosEndpointID lookup style
    
    The three-tier lookup previously mixed two idioms — an ok-check plus
    TrimSpace for metadata["id"], and a single-step assignment for
    InstanceId — which left the metadata branch calling TrimSpace twice.
    Switch metadata["id"] to the same single-step form. Go returns the
    zero value for a missing map key, so the ok-check is redundant.
    
    Behavior is unchanged; this is a readability cleanup surfaced by the
    second-round review.
    
    * docs(kvcache): document endpoint.id derivation and upgrade behavior
    
    The "Routing Contract" section already states that LMCache instance_id
    must equal pixiu endpoint.id, but did not say how endpoint.id is
    derived or what happens when pixiu's identity scheme changes. Operators
    running kvcache against the new deterministic identity could not tell
    from the doc whether the contract was still meant to hold long-term.
    
    Adds two sub-sections under "Routing Contract":
    
    - Lists the three-tier derivation order (metadata id -> InstanceId ->
      generated-<sha8>) and states that all three forms are stable across
      restarts, so the LMCache <-> pixiu contract holds long-term.
    - Calls out the one-time ID-shape transition during the upgrade from
      the previous random-UUID behavior, so LMCache operators know to
      expect a relearn window per endpoint and that it self-recovers.
    
    Mirrors the same update in the Chinese doc.
    
    * fix(nacos): fall back to cluster name for endpoint id
    
    * Refine contract definition for LMCache and pixiu
    
    Clarify the definition of the contract between LMCache instance_id and pixiu endpoint.id.
    
    * fix(model): clarify generated endpoint id encoding
    
    * ci: avoid persisted checkout credentials in integration test
---
 .github/workflows/github-actions.yml               |   4 +-
 docs/ai/kvcache.md                                 |  24 +++
 docs/ai/kvcache_CN.md                              |  21 ++
 go.mod                                             |   2 +-
 pkg/adapter/llmregistry/registry/nacos/listener.go |  29 ++-
 .../llmregistry/registry/nacos/listener_test.go    | 221 ++++++++++++++++++++-
 pkg/model/cluster.go                               |  59 ++++++
 pkg/model/cluster_test.go                          |  72 +++++++
 pkg/server/cluster_manager.go                      |  25 ++-
 pkg/server/cluster_manager_test.go                 |  85 ++++++++
 10 files changed, 523 insertions(+), 19 deletions(-)

diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml
index 548fb2c7..762d4c14 100644
--- a/.github/workflows/github-actions.yml
+++ b/.github/workflows/github-actions.yml
@@ -127,6 +127,8 @@ jobs:
       SAMPLES_CLONE_DIR: integrate_samples
     steps:
       - uses: actions/checkout@v5
+        with:
+          persist-credentials: false
       - uses: actions/setup-go@v6
         with:
           go-version-file: 'go.mod'
@@ -171,4 +173,4 @@ jobs:
       - name: Run Integration Tests
         run: |
           chmod +x start_integrate_test.sh
-          ./start_integrate_test.sh
\ No newline at end of file
+          ./start_integrate_test.sh
diff --git a/docs/ai/kvcache.md b/docs/ai/kvcache.md
index d73a2e85..7ab571db 100644
--- a/docs/ai/kvcache.md
+++ b/docs/ai/kvcache.md
@@ -43,6 +43,30 @@ So for routing to work:
 
 If no match exists, the request falls back to normal load-balancing behavior.
 
+#### How `pixiu cluster endpoint.id` is derived
+
+Pixiu picks the endpoint identifier in three-tier order. The first non-empty
+value wins:
+
+1. `metadata["id"]` on the registry instance (Nacos LLM registry), or
+   the `id:` field on a static-config endpoint.
+2. The Nacos `InstanceId` if present.
+3. A deterministic `pixiu-generated-endpoint-<sha8>` hash derived from
+   `(cluster_name, address, provider, api_key)`.
+
+All three forms are stable across pixiu restarts as long as the underlying
+attributes are unchanged, so the `LMCache instance_id ↔ pixiu endpoint.id`
+contract holds long-term.
+
+#### Upgrade note
+
+Earlier pixiu versions used a random UUID for tiers 2 and 3, which made
+`endpoint.id` change on every restart and re-subscription. The current
+version is deterministic; LMCache deployments whose `instance_id` values
+were derived from those random UUIDs will see a one-time ID-shape
+transition on upgrade. After the transition, LMCache only needs to
+relearn the `instance_id` once per endpoint; routing then stays stable.
+
 Note:
 
 - Current implementation does **not** call LMCache `/query_worker_info`.
diff --git a/docs/ai/kvcache_CN.md b/docs/ai/kvcache_CN.md
index 48cc466c..e807e227 100644
--- a/docs/ai/kvcache_CN.md
+++ b/docs/ai/kvcache_CN.md
@@ -43,6 +43,27 @@
 
 如果不一致,请求会自动回退到正常负载均衡。
 
+#### `pixiu cluster endpoint.id` 的生成规则
+
+按三档优先级取第一个非空值:
+
+1. 注册中心实例上的 `metadata["id"]`(Nacos LLM 注册中心),
+   或静态配置 endpoint 上的 `id:` 字段。
+2. Nacos 的 `InstanceId`(若存在)。
+3. 由 `(cluster_name, address, provider, api_key)` 派生的确定性哈希
+   `pixiu-generated-endpoint-<sha8>`。
+
+三种形式在 pixiu 重启之间都保持稳定,只要底层属性没有变化,所以
+`LMCache instance_id ↔ pixiu endpoint.id` 这样定义长期成立。
+
+#### 升级说明
+
+早期 pixiu 版本在第 2、3 档使用随机 UUID,导致 `endpoint.id` 每次
+重启和重订阅都会变化。当前版本改为确定性 ID;如果你的 LMCache
+部署中 `instance_id` 是基于那些随机 UUID 推导的,升级时会看到一次性
+的 ID 形态切换。切换之后,LMCache 只需为每个 endpoint 重新学习一次
+`instance_id`,之后路由恢复稳定。
+
 说明:
 
 - 当前实现 **不会** 调 LMCache `/query_worker_info`
diff --git a/go.mod b/go.mod
index e27fbe7d..574fefcd 100644
--- a/go.mod
+++ b/go.mod
@@ -28,7 +28,6 @@ require (
 	github.com/golang-jwt/jwt/v4 v4.5.2
 	github.com/golang/mock v1.6.0
 	github.com/golang/protobuf v1.5.4
-	github.com/hashicorp/go-uuid v1.0.3
 	github.com/jhump/protoreflect v1.17.0
 	github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
 	github.com/lestrrat-go/httprc/v3 v3.0.1
@@ -163,6 +162,7 @@ require (
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/go-multierror v1.1.1 // indirect
+	github.com/hashicorp/go-uuid v1.0.3 // indirect
 	github.com/hashicorp/golang-lru v0.5.4 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/hashicorp/vault/sdk v0.7.0 // indirect
diff --git a/pkg/adapter/llmregistry/registry/nacos/listener.go b/pkg/adapter/llmregistry/registry/nacos/listener.go
index ff064a80..bc3952e8 100644
--- a/pkg/adapter/llmregistry/registry/nacos/listener.go
+++ b/pkg/adapter/llmregistry/registry/nacos/listener.go
@@ -31,8 +31,6 @@ import (
 
 	"github.com/creasty/defaults"
 
-	"github.com/hashicorp/go-uuid"
-
 	"github.com/nacos-group/nacos-sdk-go/clients/naming_client"
 	nacosModel "github.com/nacos-group/nacos-sdk-go/model"
 	"github.com/nacos-group/nacos-sdk-go/vo"
@@ -275,6 +273,9 @@ func generateEndpoint(instance nacosModel.Instance) *model.Endpoint {
 		return nil
 	}
 
+	ret.Address.Address = instance.Ip
+	ret.Address.Port = int(instance.Port)
+
 	if ip, ok := instance.Metadata["ip"]; ok {
 		ret.Address.Address = ip
 	}
@@ -283,14 +284,9 @@ func generateEndpoint(instance nacosModel.Instance) *model.Endpoint {
 		p, err := strconv.Atoi(port)
 		if err != nil {
 			logger.Warnf("Invalid port in metadata: %s, error: %v", port, err)
+		} else {
+			ret.Address.Port = p
 		}
-		ret.Address.Port = p
-	}
-
-	if id, ok := instance.Metadata["id"]; ok {
-		ret.ID = id
-	} else {
-		ret.ID, _ = uuid.GenerateUUID()
 	}
 
 	if name, ok := instance.Metadata["name"]; ok {
@@ -316,10 +312,25 @@ func generateEndpoint(instance nacosModel.Instance) *model.Endpoint {
 	}
 
 	ret.Metadata = instance.Metadata
+	ret.ID = nacosEndpointID(instance, ret)
 
 	return ret
 }
 
+func nacosEndpointID(instance nacosModel.Instance, endpoint *model.Endpoint) string {
+	if id := strings.TrimSpace(instance.Metadata["id"]); id != "" {
+		return id
+	}
+	if instanceID := strings.TrimSpace(instance.InstanceId); instanceID != "" {
+		return instanceID
+	}
+	clusterName := strings.TrimSpace(instance.Metadata["cluster"])
+	if clusterName == "" {
+		clusterName = strings.TrimSpace(instance.ClusterName)
+	}
+	return model.GenerateEndpointID(clusterName, endpoint)
+}
+
 func generateInstance(ss nacosModel.SubscribeService) nacosModel.Instance {
 	return nacosModel.Instance{
 		InstanceId:  ss.InstanceId,
diff --git a/pkg/adapter/llmregistry/registry/nacos/listener_test.go b/pkg/adapter/llmregistry/registry/nacos/listener_test.go
index 6835f7d3..4fa9df8d 100644
--- a/pkg/adapter/llmregistry/registry/nacos/listener_test.go
+++ b/pkg/adapter/llmregistry/registry/nacos/listener_test.go
@@ -154,10 +154,82 @@ func TestGenerateEndpoint(t *testing.T) {
 	})
 
 	t.Run("Invalid port", func(t *testing.T) {
-		instance := nacosModel.Instance{Metadata: map[string]string{"port": "not-a-number"}}
+		instance := nacosModel.Instance{
+			Port:     8080,
+			Metadata: map[string]string{"port": "not-a-number"},
+		}
+		endpoint := generateEndpoint(instance)
+		assert.NotNil(t, endpoint)
+		assert.Equal(t, 8080, endpoint.Address.Port)
+	})
+
+	t.Run("Missing metadata ID uses Nacos instance identity", func(t *testing.T) {
+		instance := nacosModel.Instance{
+			InstanceId: "nacos-instance-1",
+			Ip:         "10.0.0.1",
+			Port:       18080,
+			Metadata: map[string]string{
+				"cluster": "llm-cluster",
+				"name":    "shared-llm",
+			},
+		}
+
 		endpoint := generateEndpoint(instance)
 		assert.NotNil(t, endpoint)
-		assert.Equal(t, 0, endpoint.Address.Port)
+		assert.Equal(t, "nacos-instance-1", endpoint.ID)
+		assert.Equal(t, "10.0.0.1", endpoint.Address.Address)
+		assert.Equal(t, 18080, endpoint.Address.Port)
+	})
+
+	t.Run("Missing ID uses stable generated endpoint ID", func(t *testing.T) {
+		instance := nacosModel.Instance{
+			Metadata: map[string]string{
+				"cluster":          "llm-cluster",
+				"name":             "shared-llm",
+				"ip":               "127.0.0.1",
+				"port":             "8080",
+				"llm-meta.api_key": "key-a",
+			},
+		}
+
+		first := generateEndpoint(instance)
+		second := generateEndpoint(instance)
+		changedCredential := instance
+		changedCredential.Metadata = map[string]string{
+			"cluster":          "llm-cluster",
+			"name":             "shared-llm",
+			"ip":               "127.0.0.1",
+			"port":             "8080",
+			"llm-meta.api_key": "key-b",
+		}
+
+		assert.NotNil(t, first)
+		assert.Equal(t, first.ID, second.ID)
+		assert.NotEqual(t, first.ID, generateEndpoint(changedCredential).ID)
+		assert.Contains(t, first.ID, "pixiu-generated-endpoint-")
+		assert.NotContains(t, first.ID, "key-a")
+	})
+
+	t.Run("Missing metadata cluster falls back to Nacos cluster name", func(t *testing.T) {
+		build := func(clusterName string) nacosModel.Instance {
+			return nacosModel.Instance{
+				Ip:          "127.0.0.1",
+				Port:        8080,
+				ClusterName: clusterName,
+				Metadata: map[string]string{
+					"name":             "shared-llm",
+					"llm-meta.api_key": "key-a",
+				},
+			}
+		}
+
+		clusterA := generateEndpoint(build("cluster-a"))
+		clusterB := generateEndpoint(build("cluster-b"))
+
+		assert.NotNil(t, clusterA)
+		assert.NotNil(t, clusterB)
+		assert.Contains(t, clusterA.ID, "pixiu-generated-endpoint-")
+		assert.NotEqual(t, clusterA.ID, clusterB.ID)
 	})
 }
 
@@ -273,6 +345,151 @@ func TestServiceCallback(t *testing.T) {
 	})
 }
 
+func TestServiceCallbackUsesStableGeneratedEndpointID(t *testing.T) {
+	l, client, adapterListener := testSetup()
+
+	_ = client.Subscribe(&vo.SubscribeParam{
+		ServiceName:       "service-generated",
+		SubscribeCallback: l.serviceCallback,
+	})
+
+	instance1 := nacosModel.SubscribeService{
+		ServiceName: "service-generated",
+		Enable:      true,
+		Healthy:     true,
+		Metadata: map[string]string{
+			"cluster":          "llm-cluster",
+			"name":             "shared-llm-a",
+			"ip":               "127.0.0.1",
+			"port":             "8080",
+			"llm-meta.api_key": "key-a",
+		},
+	}
+	instance2 := nacosModel.SubscribeService{
+		ServiceName: "service-generated",
+		Enable:      true,
+		Healthy:     true,
+		Metadata: map[string]string{
+			"cluster":          "llm-cluster",
+			"name":             "shared-llm-b",
+			"ip":               "127.0.0.1",
+			"port":             "8080",
+			"llm-meta.api_key": "key-b",
+		},
+	}
+
+	client.subscribeCallback([]nacosModel.SubscribeService{instance1, instance2}, nil)
+	assert.Len(t, adapterListener.addedEndpoints, 2)
+
+	var removedID string
+	for id, endpoint := range adapterListener.addedEndpoints {
+		if endpoint.Name == "shared-llm-a" {
+			removedID = id
+		}
+	}
+	assert.NotEmpty(t, removedID)
+
+	adapterListener.reset()
+	client.subscribeCallback([]nacosModel.SubscribeService{instance2}, nil)
+
+	assert.Empty(t, adapterListener.addedEndpoints)
+	assert.Contains(t, adapterListener.removedEndpoints, removedID)
+}
+
+func TestServiceCallbackKeepsGeneratedEndpointIDWhenNameChanges(t *testing.T) {
+	l, client, adapterListener := testSetup()
+
+	_ = client.Subscribe(&vo.SubscribeParam{
+		ServiceName:       "service-rename",
+		SubscribeCallback: l.serviceCallback,
+	})
+
+	instance := nacosModel.SubscribeService{
+		ServiceName: "service-rename",
+		Ip:          "127.0.0.1",
+		Port:        8080,
+		Enable:      true,
+		Healthy:     true,
+		Metadata: map[string]string{
+			"cluster":          "llm-cluster",
+			"name":             "shared-llm-old",
+			"llm-meta.api_key": "key-a",
+		},
+	}
+
+	client.subscribeCallback([]nacosModel.SubscribeService{instance}, nil)
+	assert.Len(t, adapterListener.addedEndpoints, 1)
+
+	var endpointID string
+	for id := range adapterListener.addedEndpoints {
+		endpointID = id
+	}
+	assert.NotEmpty(t, endpointID)
+
+	renamed := instance
+	renamed.Metadata = map[string]string{
+		"cluster":          "llm-cluster",
+		"name":             "shared-llm-new",
+		"llm-meta.api_key": "key-a",
+	}
+
+	adapterListener.reset()
+	client.subscribeCallback([]nacosModel.SubscribeService{renamed}, nil)
+
+	assert.Empty(t, adapterListener.removedEndpoints)
+	if assert.Contains(t, adapterListener.addedEndpoints, endpointID) {
+		assert.Equal(t, "shared-llm-new", adapterListener.addedEndpoints[endpointID].Name)
+	}
+}
+
+func TestServiceCallbackKeepsNacosInstancesWithoutMetadataIDDistinct(t *testing.T) {
+	l, client, adapterListener := testSetup()
+
+	_ = client.Subscribe(&vo.SubscribeParam{
+		ServiceName:       "service-instance-id",
+		SubscribeCallback: l.serviceCallback,
+	})
+
+	instance1 := nacosModel.SubscribeService{
+		InstanceId:  "nacos-instance-a",
+		ServiceName: "service-instance-id",
+		Ip:          "10.0.0.1",
+		Port:        18080,
+		Enable:      true,
+		Healthy:     true,
+		Metadata: map[string]string{
+			"cluster":          "llm-cluster",
+			"name":             "shared-llm",
+			"llm-meta.api_key": "same-key",
+		},
+	}
+	instance2 := nacosModel.SubscribeService{
+		InstanceId:  "nacos-instance-b",
+		ServiceName: "service-instance-id",
+		Ip:          "10.0.0.2",
+		Port:        18081,
+		Enable:      true,
+		Healthy:     true,
+		Metadata: map[string]string{
+			"cluster":          "llm-cluster",
+			"name":             "shared-llm",
+			"llm-meta.api_key": "same-key",
+		},
+	}
+
+	client.subscribeCallback([]nacosModel.SubscribeService{instance1, instance2}, nil)
+
+	assert.Len(t, adapterListener.addedEndpoints, 2)
+	assert.Contains(t, adapterListener.addedEndpoints, "nacos-instance-a")
+	assert.Contains(t, adapterListener.addedEndpoints, "nacos-instance-b")
+
+	adapterListener.reset()
+	client.subscribeCallback([]nacosModel.SubscribeService{instance2}, nil)
+
+	assert.Empty(t, adapterListener.addedEndpoints)
+	assert.Contains(t, adapterListener.removedEndpoints, "nacos-instance-a")
+}
+
 func TestLifecycle(t *testing.T) {
 	l, _, _ := testSetup()
 
diff --git a/pkg/model/cluster.go b/pkg/model/cluster.go
index f8bd0bac..c7ae4a49 100644
--- a/pkg/model/cluster.go
+++ b/pkg/model/cluster.go
@@ -18,9 +18,12 @@
 package model
 
 import (
+	"crypto/sha256"
 	"fmt"
 )
 
+const generatedEndpointIDPrefix = "pixiu-generated-endpoint-"
+
 const (
 	Static DiscoveryType = iota
 	StrictDNS
@@ -128,3 +131,59 @@ func (c *ClusterConfig) CreateConsistentHash() {
 func (e Endpoint) GetHost() string {
 	return fmt.Sprintf("%s:%d", e.Address.Address, e.Address.Port)
 }
+
+// GenerateEndpointID returns a deterministic runtime identity for endpoints
+// that do not provide an explicit ID. The hash material includes cluster name,
+// endpoint address, and (when present) LLM provider + API key, so endpoints
+// that differ only by credential do not collide and the same endpoint hashes
+// to the same ID across process restarts.
+//
+// Design notes:
+//   - The output is sha256(material) truncated to the first 8 bytes (64 bits).
+//     Birthday-collision probability becomes meaningful only around 2^32
+//     endpoints, far above any realistic per-cluster scale.
+//   - The output uses the pixiu-generated-endpoint- prefix so generated IDs
+//     are recognizable in logs and dashboards without implying they only come
+//     from the LLM registry path. Static-config endpoints use this helper too.
+//   - clusterName is part of the material so endpoints in different clusters
+//     never alias. Callers from the Nacos LLM path supply
+//     instance.Metadata["cluster"]; if that metadata is missing the value is
+//     the empty string, and identity is then determined by address +
+//     credential only.
+//   - The LLM API key is included on purpose so two endpoints that share an
+//     address but use different credentials never alias to the same ID. The
+//     output is one-way (sha256), so the raw key never appears in the ID.
+func GenerateEndpointID(clusterName string, endpoint *Endpoint) string {
+	sum := sha256.Sum256([]byte(endpointIDMaterial(clusterName, endpoint)))
+	return fmt.Sprintf("%s%x", generatedEndpointIDPrefix, sum[:8])
+}
+
+// endpointIDMaterial builds the byte string fed into the hash inside
+// GenerateEndpointID. Each component is tagged and length-prefixed so field
+// boundaries remain unambiguous even if values contain punctuation or newlines.
+//
+// Contract: this function MUST NOT depend on endpoint.Name. Callers
+// (notably ClusterStore.assembleClusterEndpoints) rely on being able to
+// derive the ID before assigning a default Name. Adding Name into the
+// material would also break the rename-invariance guarantee asserted by
+// TestGenerateEndpointIDIgnoresEndpointName.
+func endpointIDMaterial(clusterName string, endpoint *Endpoint) string {
+	address := ""
+	provider := ""
+	apiKey := ""
+	if endpoint != nil {
+		address = endpoint.Address.GetAddress()
+	}
+	if endpoint != nil && endpoint.LLMMeta != nil {
+		provider = endpoint.LLMMeta.Provider
+		apiKey = endpoint.LLMMeta.APIKey
+	}
+	return endpointIDMaterialField("cluster", clusterName) +
+		endpointIDMaterialField("address", address) +
+		endpointIDMaterialField("llm_provider", provider) +
+		endpointIDMaterialField("llm_api_key", apiKey)
+}
+
+func endpointIDMaterialField(name, value string) string {
+	return fmt.Sprintf("%s:%d:%s\n", name, len(value), value)
+}
diff --git a/pkg/model/cluster_test.go b/pkg/model/cluster_test.go
index bb834071..aeb26495 100644
--- a/pkg/model/cluster_test.go
+++ b/pkg/model/cluster_test.go
@@ -102,3 +102,75 @@ func TestEndpoint_GetHost(t *testing.T) {
 
 	assert.Equal(t, "127.0.0.1:20880", endpoint.GetHost())
 }
+
+func TestGenerateEndpointIDIsDeterministic(t *testing.T) {
+	build := func(apiKey string) *model.Endpoint {
+		return &model.Endpoint{
+			Name:    "shared-llm",
+			Address: model.SocketAddress{Address: "127.0.0.1", Port: 20880},
+			LLMMeta: &model.LLMMeta{Provider: "openai", APIKey: apiKey},
+		}
+	}
+
+	first := model.GenerateEndpointID("cluster-a", build("key-a"))
+	second := model.GenerateEndpointID("cluster-a", build("key-a"))
+	other := model.GenerateEndpointID("cluster-a", build("key-b"))
+
+	assert.Equal(t, first, second, "same material must produce same ID")
+	assert.NotEqual(t, first, other, "different credential must produce different ID")
+	assert.True(t, strings.HasPrefix(first, "pixiu-generated-endpoint-"),
+		"ID must use pixiu-generated-endpoint- prefix")
+	assert.NotContains(t, first, "key-a", "raw API key must not leak into the ID")
+}
+
+func TestGenerateEndpointIDIgnoresEndpointName(t *testing.T) {
+	base := &model.Endpoint{
+		Name:    "old-name",
+		Address: model.SocketAddress{Address: "127.0.0.1", Port: 20880},
+		LLMMeta: &model.LLMMeta{Provider: "openai", APIKey: "k"},
+	}
+	renamed := *base
+	renamed.Name = "new-name"
+
+	assert.Equal(t,
+		model.GenerateEndpointID("cluster-a", base),
+		model.GenerateEndpointID("cluster-a", &renamed),
+	)
+}
+
+func TestGenerateEndpointIDSeparatesClusters(t *testing.T) {
+	endpoint := &model.Endpoint{
+		Address: model.SocketAddress{Address: "127.0.0.1", Port: 20880},
+		LLMMeta: &model.LLMMeta{Provider: "openai", APIKey: "k"},
+	}
+
+	a := model.GenerateEndpointID("cluster-a", endpoint)
+	b := model.GenerateEndpointID("cluster-b", endpoint)
+
+	assert.NotEqual(t, a, b, "different cluster names must produce different IDs for the same endpoint")
+}
+
+func TestGenerateEndpointIDSeparatesAddresses(t *testing.T) {
+	build := func(host string, port int) *model.Endpoint {
+		return &model.Endpoint{
+			Address: model.SocketAddress{Address: host, Port: port},
+			LLMMeta: &model.LLMMeta{Provider: "openai", APIKey: "k"},
+		}
+	}
+
+	base := model.GenerateEndpointID("cluster-a", build("127.0.0.1", 20880))
+
+	assert.NotEqual(t, base,
+		model.GenerateEndpointID("cluster-a", build("127.0.0.2", 20880)),
+		"different host must produce different ID")
+	assert.NotEqual(t, base,
+		model.GenerateEndpointID("cluster-a", build("127.0.0.1", 20881)),
+		"different port must produce different ID")
+}
+
+func TestGenerateEndpointIDHandlesNilEndpoint(t *testing.T) {
+	a := model.GenerateEndpointID("cluster-a", nil)
+	b := model.GenerateEndpointID("cluster-b", nil)
+	assert.True(t, strings.HasPrefix(a, "pixiu-generated-endpoint-"))
+	assert.NotEqual(t, a, b)
+}
diff --git a/pkg/server/cluster_manager.go b/pkg/server/cluster_manager.go
index 112440c3..56f7fb78 100644
--- a/pkg/server/cluster_manager.go
+++ b/pkg/server/cluster_manager.go
@@ -23,10 +23,6 @@ import (
 	"sync/atomic"
 )
 
-import (
-	"github.com/hashicorp/go-uuid"
-)
-
 import (
 	"github.com/apache/dubbo-go-pixiu/pkg/cluster"
 	"github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer"
@@ -316,10 +312,27 @@ func (s *ClusterStore) assembleClusterEndpoints(c *model.ClusterConfig) {
 		return
 	}
 
+	// generatedIDIndex tracks the slice position where each generated ID was
+	// first observed within this cluster. A duplicate means two endpoints
+	// share (cluster_name, address, provider, api_key) and collapse onto a
+	// single identifier, which is silent in the runtime path; warn loudly so
+	// the operator can disambiguate by setting an explicit `id:`.
+	generatedIDIndex := make(map[string]int, len(c.Endpoints))
+
 	for i, endpoint := range c.Endpoints {
-		// If the endpoint ID is not set, set it to the index + 1
+		// If the endpoint ID is not set, derive a deterministic one so the same
+		// endpoint hashes to the same ID across process restarts. Identifiers
+		// must be stable for metrics/log correlation and runtime lookups
+		// (PickNextEndpoint, GetEndpointByID, DeleteEndpoint).
 		if endpoint.ID == "" {
-			endpoint.ID, _ = uuid.GenerateUUID()
+			endpoint.ID = model.GenerateEndpointID(c.Name, endpoint)
+			if prev, dup := generatedIDIndex[endpoint.ID]; dup {
+				logger.Warnf("[cluster=%s] endpoints[%d] and endpoints[%d] share generated ID %s; "+
+					"set an explicit `id:` on one of them to keep them distinct",
+					c.Name, prev, i, endpoint.ID)
+			} else {
+				generatedIDIndex[endpoint.ID] = i
+			}
 		}
 
 		// If the endpoint has no name, set a default name
diff --git a/pkg/server/cluster_manager_test.go b/pkg/server/cluster_manager_test.go
index ef90bd3c..e72b3c87 100644
--- a/pkg/server/cluster_manager_test.go
+++ b/pkg/server/cluster_manager_test.go
@@ -500,6 +500,91 @@ func TestClusterManager_Race_RoundRobinPickEndpoint(t *testing.T) {
 	wg.Wait()
 }
 
+func TestClusterManager_AssembleEndpointsAssignsDeterministicID(t *testing.T) {
+	build := func() *model.ClusterConfig {
+		return &model.ClusterConfig{
+			Name:  "assemble-id",
+			LbStr: model.LoadBalancerRoundRobin,
+			Endpoints: []*model.Endpoint{
+				{Address: model.SocketAddress{Address: "127.0.0.1", Port: 21080}},
+				{Address: model.SocketAddress{Address: "127.0.0.1", Port: 21081}},
+			},
+		}
+	}
+
+	first := testClusterManager(build())
+	defer stopStoreRuntimes(first.store)
+	second := testClusterManager(build())
+	defer stopStoreRuntimes(second.store)
+
+	firstEPs := first.store.Config[0].Endpoints
+	secondEPs := second.store.Config[0].Endpoints
+
+	if !assert.Len(t, firstEPs, 2) || !assert.Len(t, secondEPs, 2) {
+		return
+	}
+
+	// Deterministic: identical static config produces identical IDs across
+	// independent constructions, so dashboards keyed on endpoint.ID survive
+	// process restart.
+	assert.Equal(t, firstEPs[0].ID, secondEPs[0].ID)
+	assert.Equal(t, firstEPs[1].ID, secondEPs[1].ID)
+
+	// Generated prefix indicates the deterministic helper, not the legacy
+	// random UUID fallback.
+	assert.Contains(t, firstEPs[0].ID, "pixiu-generated-endpoint-")
+
+	// Endpoints differing only by port must not collide within the same cluster.
+	assert.NotEqual(t, firstEPs[0].ID, firstEPs[1].ID)
+}
+
+func TestClusterManager_AssembleEndpointsPreservesExplicitID(t *testing.T) {
+	cluster := &model.ClusterConfig{
+		Name:  "explicit-id",
+		LbStr: model.LoadBalancerRoundRobin,
+		Endpoints: []*model.Endpoint{
+			{ID: "operator-pinned", Address: model.SocketAddress{Address: "127.0.0.1", Port: 21082}},
+		},
+	}
+
+	cm := testClusterManager(cluster)
+	defer stopStoreRuntimes(cm.store)
+
+	if assert.Len(t, cm.store.Config[0].Endpoints, 1) {
+		assert.Equal(t, "operator-pinned", cm.store.Config[0].Endpoints[0].ID)
+	}
+}
+
+// TestClusterManager_AssembleEndpointsCollapseDuplicateGenerated locks the
+// documented collapse behavior: two static endpoints sharing every input that
+// feeds GenerateEndpointID (cluster name, address, LLM credential) end up with
+// the same generated ID, and operators must set an explicit `id:` to keep
+// them distinct.
+func TestClusterManager_AssembleEndpointsCollapseDuplicateGenerated(t *testing.T) {
+	cluster := &model.ClusterConfig{
+		Name:  "collapse-id",
+		LbStr: model.LoadBalancerRoundRobin,
+		Endpoints: []*model.Endpoint{
+			{Address: model.SocketAddress{Address: "127.0.0.1", Port: 21083}},
+			{Address: model.SocketAddress{Address: "127.0.0.1", Port: 21083}},
+		},
+	}
+
+	cm := testClusterManager(cluster)
+	defer stopStoreRuntimes(cm.store)
+
+	endpoints := cm.store.Config[0].Endpoints
+	if !assert.Len(t, endpoints, 2) {
+		return
+	}
+
+	// Both endpoints survived in the slice (we do not silently dedupe) but
+	// share the same generated identifier.
+	assert.Equal(t, endpoints[0].ID, endpoints[1].ID,
+		"endpoints with identical hash material must collapse to the same ID")
+	assert.Contains(t, endpoints[0].ID, "pixiu-generated-endpoint-")
+}
+
 func testClusterManager(clusters ...*model.ClusterConfig) *ClusterManager {
 	return CreateDefaultClusterManager(&model.Bootstrap{
 		StaticResources: model.StaticResources{