Repository: cloudwego/eino
Pull request #137: feat: introduce RegisterValuesMergeFunc to support custom merging
Author: login: wolfogre
Created: 2025-03-27T08:55:34Z | Merged: 2025-04-21T10:02:18Z

#### What type of PR is this?

feat: A new feature

#### Check the PR title.

- [x] This PR title match the format: \<type\>(optional scope): \<description\>
- [x] The description of this PR title is user-oriented and clear enough for others to understand.
- [x] Attach the PR updating the user documentation if the current PR requires user awareness at the usage level. [User docs repo](https://github.com/cloudwego/cloudwego.github.io)


#### (Optional) Translate the PR title into Chinese.

引入 `RegisterValuesMergeFunc` 以支持自定义合并。

#### (Optional) More detailed description for this PR(en: English/zh: Chinese).

en:

This PR is an implementation of the proposal #132.

It introduces a new package-level function `RegisterValuesMergeFunc`, which allows users to register a custom merge function for a specific type.

The registered function will be used to merge the outputs of multiple nodes when fan-in.

The PR also does some minor refactoring, moving the functions used for merging that were previously scattered in `utils.go` out and reorganizing them.

zh(optional): 

这个 PR 是对提案 #132 的实现。

它引入了一个新的包级函数 `RegisterValuesMergeFunc`，允许用户为特定类型注册自定义的合并函数。

注册的函数将在多个节点扇入时，用于合并它们的输出。

该 PR 做了一些小的重构，将原本分散在 `utils.go` 里用于合并的函数移出并做了重新组织。


#### (Optional) Which issue(s) this PR fixes:

Close #132.

#### (optional) The PR that updates user documentation:

- https://github.com/cloudwego/cloudwego.github.io/pull/1292


Linked issue #132: [Proposal] Support for custom merge methods
Currently, eino only supports maps when merging the outputs of multiples nodes.

See the [doc](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/orchestration_design_principles/#%E6%89%87%E5%85%A5%E4%B8%8E%E5%90%88%E5%B9%B6).

The limitation bothered me many times, it seems what I need is the "custom merge method" feature. See https://github.com/cloudwego/eino/pull/73#issuecomment-2667488838

~This proposal issue describes a way to approach that, and #137 is a proof of concept for it. If it's acceptable and there are no others working on it, I will finish the PR.~

#137 will implement it.

## Proposal

1. Define a new package function ~`func RegisterFanInMergeFunc[T any](fn func([]T) (T, error))`~ `func RegisterValuesMergeFunc[T any](fn func([]T) (T, error))`.

Users can register a custom merge function for a specific type `T`.
The function should take a slice of `T` and return a single `T` and an error.

2. Use the registered merge function first when merging the outputs of multiple nodes.

When merging the outputs of multiple nodes, check if a custom merge function is registered for the type of the output.
If so, use that function to merge the outputs. If not, fall back to the default map merging behavior.

The check for a custom merge function should take precedence over the check for map type.
So if users register a custom merge function for a map type like `type MyOutput map[string]any`,
the custom merge function should be used instead of the default map merging behavior.

## Deprecated proposal

The deprecated approach, which is based on adding new `Merge` methods for types.

#133 is a proof of concept for it

<details>
  <summary>Click to expand</summary>


1. Define a new interface with generics

```go
type Mergeable[T any] interface {
	Merge(others ...T) T
}
```

Users can implement it to make their custom type mergeable.

However, it's just a guidance for eino users.
It's an interface with generics, so it cannot be used as type assertion like common interfaces like `v.(Mergeable)`, since the right syntax is `v.(Mergeable[T])` but the T is unknown at that time.

2. Check if the type implements the interface by reflection

Duo to the limitation of Go's generics, we cannot use type assertion to check if a type implements the interface.

So we have to use reflection to check it, it's not difficult, and I believe the performance is acceptable.

3. Call the Merge method when merging

When merging outputs, if the type implements the interface, call the Merge method to merge the values.

This check is higher priority than checking if it's a map, so it means if it's a map and implements the interface,
we will call the Merge method to merge the values.

### More thoughts

1. This approach is not perfect: "A type implementing the Mergeable interface (in a wrong way) can still cannot be merged."

We expect the user to implement the Merge method like:

```go
func (MyType) Merge(others ...MyType) MyType {/*...*/}

var _ Mergeable[MyType] = (*MyType)(nil)
```

However, a bad case can be:

```go
func (MyType) Merge(others ...OtherType) OtherType {/*...*/}

var _ Mergeable[OtherType] = (*MyType)(nil)
```

Although I believe most developers will not do this, the code syntax is correct.
But the type is not mergeable since the type of the return value is not the same as the type of the receiver.
So we have to provide a function like `IsMergeable` if the type is really mergeable.

2. Registering merging functions could be another approach.

Something like:

```go
type MergeFunc[T any] func(values ...T) T

func RegisterMergeFunc[T any](f MergeFunc[T]) {
    // register the function
}
```

Then find the function by type when merging.

But I don't like this "register before use" way personally, I don't think it brings any benefit to users or developers of eino.

</details>

The key reasons to give it up are:
1. the person defining the model and the one using might be different individuals, see https://github.com/cloudwego/eino/issues/132#issuecomment-2751262182
2. Keep the same style of the implementation of contacting stream chunks, see https://github.com/cloudwego/eino/blob/cde10e9fb08cfb885de37f98ceafb9cfb750ebe9/compose/stream_concat.go#L45

Thanks @kuhahalong for pointing them out.
Linked issue comments: user: login: kuhahalong
created_at: 2025-03-25T13:27:16Z
body: Thanks for your proposal. 

I reviewed the discussion in https://github.com/cloudwego/eino/pull/73 and noticed there might be a misunderstanding: Eino does support custom concat logic for specific types. This is implemented via the mentioned option "2. Registering merging functions...". For more details, you can refer to https://github.com/cloudwego/eino/blob/main/compose/stream_concat.go#L45. The exposed method is: `func RegisterStreamChunkConcatFunc[T any](fn func([]T) (T, error))`.

As for why we chose a registration mechanism instead of defining an Mergable interface, one consideration was the possibility that the person defining the model and the one using might be different individuals. The registration approach allows users to define their own merging logic flexibly.
author_association: CONTRIBUTOR
user: login: wolfogre
created_at: 2025-03-25T16:05:16Z
body: Hmm, I'm not sure if we are talking about the same thing. Let's make it clear.

1. If I understand correctly, `RegisterStreamChunkConcatFunc` is used to contact chunks in the same stream, it has nothing to do with merging outputs from multiple nodes, right?

2. This proposal is trying to find a way to support custom methods to merge outputs from multiple nodes, which currently only supports merging maps, see the [doc](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/orchestration_design_principles/#%E6%89%87%E5%85%A5%E4%B8%8E%E5%90%88%E5%B9%B6). It's not really related to the stream, but the limitation requires that the item type in stream readers should be maps when merging them.

@meguminnnnnnnnn Could you help clarify this? When you mentioned "custom merge method" in https://github.com/cloudwego/eino/pull/73#issuecomment-2667469302, did you mean `RegisterStreamChunkConcatFunc`?

---

> ... the person defining the model and the one using might be different individuals. The registration approach allows users to define their own merging logic flexibly.

@kuhahalong Thank you for pointing it out. I overlooked those cases, and yes, you are right, I agree with you now, a registration mechanism  should be a better approach.

So I will figure out if there's already a way (maybe `RegisterStreamChunkConcatFunc`) to do that. If yes, I'll close this issue and the related PR. If no, I'll update this proposal in the registration mechanism way and finish the PR. What do you think?

author_association: CONTRIBUTOR
user: login: meguminnnnnnnnn
created_at: 2025-03-26T03:34:53Z
body: Hello, merge and concat are different mechanisms. Currently, there is RegisterStreamChunkConcatFunc but no RegisterMergeFunc, so Eino now only supports merging maps, as I mentioned earlier.

The reason we didn't initially support registering a merge function is that we didn't think of practical use cases for it because merge usually takes effect on fan-in nodes, and fan-in operations often require WithOutputKey to indicate which upstream value it comes from, meaning that merge other than map merge is usually not needed. Have you encountered scenarios where merge is required?
author_association: CONTRIBUTOR
user: login: kuhahalong
created_at: 2025-03-26T03:38:49Z
body: Alright, I misunderstood earlier. This refers to the issue of merging multiple edges connected to a single node, rather than concatenating multiple chunks in a stream.
author_association: CONTRIBUTOR
user: login: wolfogre
created_at: 2025-03-26T07:12:23Z
body: @meguminnnnnnnnn Thank you for your explanation. And here is my scenario (the description has been simplified):

1. The original graph looks like:

```mermaid
graph TD;
    X-->A;
    A-->|MyOutput|Z;
```

`MyOutput` is something like:

```go
type MyOutput struct {
	Documents []*Document
}
```

You can imagine that node A finds some documents and puts them into node Z.

2. One day, I need to add a parallel node

```mermaid
graph TD;
    X-->A;
    X-->B;
    A-->|MyOutput|Z;
    B-->|MyOutput|Z;
```

The outputs of A and B are the same type. You can imagine that nodes A and B find the documents in different ways, and the `Document` has a `Source` field to indicate where it comes from.

3. Unfortunately, the graph doesn't work.

Since `MyOutput` is not a map type, so I have to convert the output into maps with `WithOutputKey`.

But the input type of node Z is `MyOutput`, I have to add a new node Y to convert the maps to `MyOutput`.

```mermaid
graph TD;
    X-->A;
    X-->B;
    A-->|"map[string]MyOutput"|Y;
    B-->|"map[string]MyOutput"|Y;
	Y-->|MyOutput|Z;
```

4. The new node Y is boring and dirty.

You can imagine that the work of node Y is something like:

```go
for _, v := range inputs {
	output.Documents = append(output.Documents, v.Documents)
}
```

It's OK to do that once, but every time I want to add a new parallel node to the graph, I have to do it again and again. 😢

And I don't know if there's a better way to do that, but you can see that what node Y does is exactly "merge `MyOutput` in the custom method", so I hope to introduce a more elegant way into eino.
author_association: CONTRIBUTOR
user: login: meguminnnnnnnnn
created_at: 2025-03-26T09:10:56Z
body: I understand. I have a bit of confusion that if merge is used, node Z will not be able to distinguish which part of the input comes from A and which part comes from B. If the outputs of A and B are homogeneous so that Z doesn't need to distinguish them, then they might need to be generated in a single node?
author_association: CONTRIBUTOR
user: login: wolfogre
created_at: 2025-03-26T11:54:26Z
body: Think about this, 
- node A is "searching for information from Google in English"
- node B is "searching for information from Baidu in Chinese"
- node Z is "finding the answer from the searched results"

So

> node Z will not be able to distinguish which part of the input comes from A and which part comes from B.

It doesn't need to; it doesn't really care about where it comes from for each result. And if it wants to do that, as I mentioned before, "the Document has a `Source` field to indicate where it comes from", node Z can check the `Source` of each results, "Google" or "Baidu".

> If the outputs of A and B are homogeneous so that Z doesn't need to distinguish them, then they might need to be generated in a single node?

I understand your idea, and I believe it will work. However, I would say A and B are not really homogeneous, their implementation code may be completely different. They just have a homogeneous output structure and share the output type.

If you think it's OK to do "searching from Google" and "searching from Baidu" in one node, I would say maybe I'll want to add more ways into this node one day, "searching from an internal ES", "searching from an viking DB", yes, I'm talking about the generalized "retrieval" in RAG. I believe you can imagine that, although it's not really necessary, splitting them into multiple nodes will make each node easier to maintain, observe and debug.

PS: in my real scenario, the node A and B are even more different in implementation. But the underlying reason is the same as described above.
author_association: CONTRIBUTOR
user: login: meguminnnnnnnnn
created_at: 2025-03-26T12:01:47Z
body: Got. Add a merge method is reasonable and I will review your PR. Thanks for your contributes~
author_association: CONTRIBUTOR
user: login: wolfogre
created_at: 2025-03-27T03:29:08Z
body: @meguminnnnnnnnn Thank you for your understanding. I have updated the proposal to use the registration mechanism and deprecated the old one. Please review it first to ensure it is acceptable. If yes (I hope so 😁), I'll continue the work on the PR.
author_association: CONTRIBUTOR
user: login: wolfogre
created_at: 2025-04-22T08:38:10Z
body: @meguminnnnnnnnn Could you please reopen this issue?

I tested eino v0.3.26 and found that there are some problems with ⁠`RegisterValuesMergeFunc` in certain extreme scenarios.

Don't worry, it has no impact if users don't use it, and I'm trying to figure it out.
author_association: CONTRIBUTOR
user: login: meguminnnnnnnnn
created_at: 2025-04-22T08:40:02Z
body: could you describe the issue briefly?
author_association: CONTRIBUTOR
user: login: wolfogre
created_at: 2025-04-22T13:35:18Z
body: @meguminnnnnnnnn As we discussed, the ⁠RegisterValuesMergeFunc may not work as expected in the streams mode, and the same issue also appears in maps. However, since the scenario is quite specific and lacks real-world scenario, we will temporarily ignore this issue.

I have retained the code related to the attempted fix for reference if needed.

https://github.com/wolfogre/eino/pull/1/files
author_association: CONTRIBUTOR

Diff:
diff --git a/compose/utils.go b/compose/utils.go
index 7bc400efd..faf3dfe9a 100644
--- a/compose/utils.go
+++ b/compose/utils.go
@@ -27,68 +27,6 @@ import (
 	"github.com/cloudwego/eino/schema"
 )
 
-func mergeMap(vs []any) (any, error) {
-	typ := reflect.TypeOf(vs[0])
-	merged := reflect.MakeMap(typ)
-	for _, v := range vs {
-		if reflect.TypeOf(v) != typ {
-			return nil, fmt.Errorf(
-				"(mergeMap) field type mismatch. expected: '%v', got: '%v'", typ, reflect.TypeOf(v))
-		}
-
-		iter := reflect.ValueOf(v).MapRange()
-		for iter.Next() {
-			key, val := iter.Key(), iter.Value()
-			if merged.MapIndex(key).IsValid() {
-				return nil, fmt.Errorf("(mergeMap) duplicated key ('%v') found", key.Interface())
-			}
-			merged.SetMapIndex(key, val)
-		}
-	}
-
-	return merged.Interface(), nil
-}
-
-// the caller should ensure len(vs) > 1
-func mergeValues(vs []any) (any, error) {
-	v0 := reflect.ValueOf(vs[0])
-	t0 := v0.Type()
-	k0 := t0.Kind()
-
-	if k0 == reflect.Map {
-		return mergeMap(vs)
-	}
-
-	if s, ok := vs[0].(streamReader); ok {
-		if s.getChunkType().Kind() != reflect.Map {
-			return nil, fmt.Errorf("(mergeValues | stream type)"+
-				" unsupported chunk type: %v", s.getChunkType())
-		}
-
-		ss := make([]streamReader, len(vs)-1)
-		for i := 0; i < len(ss); i++ {
-			s_, ok_ := vs[i+1].(streamReader)
-			if !ok_ {
-				return nil, fmt.Errorf("(mergeStream) unexpected type. "+
-					"expect: %v, got: %v", t0, reflect.TypeOf(vs[i]))
-			}
-
-			if s_.getChunkType() != s.getChunkType() {
-				return nil, fmt.Errorf("(mergeStream) chunk type mismatch. "+
-					"expect: %v, got: %v", s.getChunkType(), s_.getChunkType())
-			}
-
-			ss[i] = s_
-		}
-
-		ms := s.merge(ss)
-
-		return ms, nil
-	}
-
-	return nil, fmt.Errorf("(mergeValues) unsupported type: %v", t0)
-}
-
 type on[T any] func(context.Context, T) (context.Context, T)
 
 func onStart[T any](ctx context.Context, input T) (context.Context, T) {
diff --git a/compose/utils_test.go b/compose/utils_test.go
index 3215e8d20..07a781c2f 100644
--- a/compose/utils_test.go
+++ b/compose/utils_test.go
@@ -17,62 +17,13 @@
 package compose
 
 import (
-	"io"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 
 	"github.com/cloudwego/eino/internal/generic"
-	"github.com/cloudwego/eino/schema"
 )
 
-func TestMergeValues(t *testing.T) {
-	// merge maps
-	m1 := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
-	m2 := map[int]int{5: 5, 6: 6, 7: 7, 8: 8}
-	m3 := map[int]int{9: 9, 10: 10, 11: 11}
-	mergedM, err := mergeValues([]any{m1, m2, m3})
-	assert.Nil(t, err)
-
-	m := mergedM.(map[int]int)
-
-	// len(m) == len(m1) + len(m2) + len(m3)
-	assert.Equal(t, len(m), len(m1)+len(m2)+len(m3))
-
-	_, err = mergeValues([]any{m1, m2, m3, map[int]int{1: 1}})
-	assert.NotNil(t, err)
-
-	_, err = mergeValues([]any{m1, m2, m3, map[int]string{1: "1"}})
-	assert.NotNil(t, err)
-
-	// merge stream
-	ass := []any{
-		packStreamReader(schema.StreamReaderFromArray[map[int]bool]([]map[int]bool{{1: true}})),
-		packStreamReader(schema.StreamReaderFromArray[map[int]bool]([]map[int]bool{{2: true}})),
-		packStreamReader(schema.StreamReaderFromArray[map[int]bool]([]map[int]bool{{3: true}})),
-	}
-	isr, err := mergeValues(ass)
-	assert.Nil(t, err)
-	ret, ok := unpackStreamReader[map[int]bool](isr.(streamReader))
-	defer ret.Close()
-
-	// check if merge ret is StreamReader
-	assert.True(t, ok)
-
-	for i := 1; i <= 3; i++ {
-		num, err := ret.Recv()
-		assert.Nil(t, err)
-
-		if num[i] != true {
-			t.Fatalf("stream read num:%d is out of expect", i)
-		}
-	}
-	_, err = ret.Recv()
-	if err != io.EOF {
-		t.Fatalf("stream reader isn't return EOF as expect: %v", err)
-	}
-}
-
 type good interface {
 	ThisIsGood() bool
 }
diff --git a/compose/values_merge.go b/compose/values_merge.go
new file mode 100644
index 000000000..965a2a7cd
--- /dev/null
+++ b/compose/values_merge.go
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package compose
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/cloudwego/eino/internal"
+)
+
+// RegisterValuesMergeFunc registers a function to merge outputs from multiple nodes when fan-in.
+// It's used to define how to merge for a specific type.
+// For maps that already have a default merge function, you don't need to register a new one unless you want to customize the merge logic.
+func RegisterValuesMergeFunc[T any](fn func([]T) (T, error)) {
+	internal.RegisterValuesMergeFunc(fn)
+}
+
+// the caller should ensure len(vs) > 1
+func mergeValues(vs []any) (any, error) {
+	v0 := reflect.ValueOf(vs[0])
+	t0 := v0.Type()
+
+	if fn := internal.GetMergeFunc(t0); fn != nil {
+		return fn(vs)
+	}
+
+	if s, ok := vs[0].(streamReader); ok {
+		t := s.getChunkType()
+		if internal.GetMergeFunc(t) == nil {
+			return nil, fmt.Errorf("(mergeValues | stream type)"+
+				" unsupported chunk type: %v", t)
+		}
+
+		ss := make([]streamReader, len(vs)-1)
+		for i := 0; i < len(ss); i++ {
+			s_, ok_ := vs[i+1].(streamReader)
+			if !ok_ {
+				return nil, fmt.Errorf("(mergeStream) unexpected type. "+
+					"expect: %v, got: %v", t0, reflect.TypeOf(vs[i]))
+			}
+
+			if st := s_.getChunkType(); st != t {
+				return nil, fmt.Errorf("(mergeStream) chunk type mismatch. "+
+					"expect: %v, got: %v", t, st)
+			}
+
+			ss[i] = s_
+		}
+
+		ms := s.merge(ss)
+
+		return ms, nil
+	}
+
+	return nil, fmt.Errorf("(mergeValues) unsupported type: %v", t0)
+}
diff --git a/compose/values_merge_test.go b/compose/values_merge_test.go
new file mode 100644
index 000000000..948a57330
--- /dev/null
+++ b/compose/values_merge_test.go
@@ -0,0 +1,195 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package compose
+
+import (
+	"fmt"
+	"io"
+	"sort"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/cloudwego/eino/schema"
+)
+
+func Test_mergeValues(t *testing.T) {
+	t.Run("merge maps", func(t *testing.T) {
+		m1 := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
+		m2 := map[int]int{5: 5, 6: 6, 7: 7, 8: 8}
+		m3 := map[int]int{9: 9, 10: 10, 11: 11}
+
+		t.Run("regular", func(t *testing.T) {
+			mergedM, err := mergeValues([]any{m1, m2, m3})
+			assert.NoError(t, err)
+
+			m := mergedM.(map[int]int)
+
+			// len(m) == len(m1) + len(m2) + len(m3)
+			assert.Equal(t, len(m), len(m1)+len(m2)+len(m3))
+		})
+
+		t.Run("duplicated key", func(t *testing.T) {
+			_, err := mergeValues([]any{m1, m2, m3, map[int]int{1: 1}})
+			assert.ErrorContains(t, err, "duplicated key")
+		})
+
+		t.Run("type mismatch", func(t *testing.T) {
+			_, err := mergeValues([]any{m1, m2, m3, map[int]string{1: "1"}})
+			assert.ErrorContains(t, err, "type mismatch")
+		})
+	})
+
+	t.Run("merge stream", func(t *testing.T) {
+		ass := []any{
+			packStreamReader(schema.StreamReaderFromArray[map[int]string]([]map[int]string{{1: "1"}})),
+			packStreamReader(schema.StreamReaderFromArray[map[int]string]([]map[int]string{{2: "2"}})),
+			packStreamReader(schema.StreamReaderFromArray[map[int]string]([]map[int]string{{3: "3", 4: "4"}})),
+		}
+		isr, err := mergeValues(ass)
+		require.NoError(t, err)
+		ret, ok := unpackStreamReader[map[int]string](isr.(streamReader))
+		require.True(t, ok)
+		defer ret.Close()
+
+		got := make(map[int]string)
+		for i := 0; i < 3; i++ {
+			m, err := ret.Recv()
+			require.NoError(t, err)
+			for k, v := range m {
+				got[k] = v
+			}
+		}
+		_, err = ret.Recv()
+		require.ErrorIs(t, err, io.EOF)
+
+		assert.Equal(t, map[int]string{
+			1: "1",
+			2: "2",
+			3: "3",
+			4: "4",
+		}, got)
+	})
+
+	type TestType struct {
+		A int
+		B []string
+	}
+
+	RegisterValuesMergeFunc(func(vs []*TestType) (*TestType, error) {
+		ret := &TestType{}
+		for _, v := range vs {
+			if v == nil {
+				continue
+			}
+			if ret.A < 0 {
+				return nil, fmt.Errorf("test error: %v", ret.A)
+			}
+			ret.A += v.A
+			ret.B = append(ret.B, v.B...)
+		}
+		sort.Strings(ret.B)
+		return ret, nil
+	})
+
+	t.Run("custom merge", func(t *testing.T) {
+		t.Run("regular", func(t *testing.T) {
+			vs := []any{
+				&TestType{A: 0, B: []string{}},
+				&TestType{A: 1, B: []string{"1"}},
+				&TestType{A: 2, B: []string{"2", "22"}},
+				&TestType{A: 3, B: []string{"3", "33", "333"}},
+			}
+			ret, err := mergeValues(vs)
+			require.NoError(t, err)
+			assert.Equal(t, &TestType{
+				A: 6,
+				B: []string{"1", "2", "22", "3", "33", "333"},
+			}, ret)
+		})
+
+		t.Run("custom error", func(t *testing.T) {
+			vs := []any{
+				&TestType{A: 0, B: []string{}},
+				&TestType{A: 1, B: []string{"1"}},
+				&TestType{A: -2, B: []string{"2", "22"}},
+				&TestType{A: 3, B: []string{"3", "33", "333"}},
+			}
+			_, err := mergeValues(vs)
+			require.ErrorContains(t, err, "test error")
+		})
+
+		t.Run("type mismatch", func(t *testing.T) {
+			vs := []any{
+				&TestType{A: 0, B: []string{}},
+				&TestType{A: 1, B: []string{"1"}},
+				&TestType{A: 2, B: []string{"2", "22"}},
+				"test3",
+			}
+			_, err := mergeValues(vs)
+			require.ErrorContains(t, err, "type mismatch")
+		})
+
+		t.Run("stream", func(t *testing.T) {
+			ass := []any{
+				packStreamReader(schema.StreamReaderFromArray([]*TestType{
+					{A: 0, B: []string{}},
+				})),
+				packStreamReader(schema.StreamReaderFromArray([]*TestType{
+					{A: 1, B: []string{"1"}},
+				})),
+				packStreamReader(schema.StreamReaderFromArray([]*TestType{
+					{A: 2, B: []string{"2", "22"}},
+				})),
+				packStreamReader(schema.StreamReaderFromArray([]*TestType{
+					{A: 3, B: []string{"3", "33", "333"}},
+				})),
+			}
+			isr, err := mergeValues(ass)
+			require.NoError(t, err)
+			ret, ok := unpackStreamReader[*TestType](isr.(streamReader))
+			require.True(t, ok)
+			defer ret.Close()
+
+			var vs []any
+			for i := 0; i < 4; i++ {
+				v, err := ret.Recv()
+				require.NoError(t, err)
+				vs = append(vs, v)
+			}
+
+			_, err = ret.Recv()
+			require.ErrorIs(t, err, io.EOF)
+
+			merged, err := mergeValues(vs)
+			require.NoError(t, err)
+
+			assert.Equal(t, &TestType{
+				A: 6,
+				B: []string{"1", "2", "22", "3", "33", "333"},
+			}, merged)
+		})
+	})
+
+	t.Run("unregistered type", func(t *testing.T) {
+		type Unregistered TestType
+		_, err := mergeValues([]any{&Unregistered{}})
+		assert.ErrorContains(t, err, "unsupported type")
+	})
+
+}
diff --git a/internal/merge.go b/internal/merge.go
new file mode 100644
index 000000000..6d0f94de9
--- /dev/null
+++ b/internal/merge.go
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package internal
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/cloudwego/eino/internal/generic"
+)
+
+var mergeFuncs = map[reflect.Type]any{}
+
+func RegisterValuesMergeFunc[T any](fn func([]T) (T, error)) {
+	mergeFuncs[generic.TypeOf[T]()] = fn
+}
+
+func GetMergeFunc(typ reflect.Type) func([]any) (any, error) {
+	if fn, ok := mergeFuncs[typ]; ok {
+		return func(vs []any) (any, error) {
+			rvs := reflect.MakeSlice(reflect.SliceOf(typ), 0, len(vs))
+			for _, v := range vs {
+				if t := reflect.TypeOf(v); t != typ {
+					return nil, fmt.Errorf(
+						"(values merge) field type mismatch. expected: '%v', got: '%v'", typ, t)
+				}
+				rvs = reflect.Append(rvs, reflect.ValueOf(v))
+			}
+
+			rets := reflect.ValueOf(fn).Call([]reflect.Value{rvs})
+			var err error
+			if !rets[1].IsNil() {
+				err = rets[1].Interface().(error)
+			}
+			return rets[0].Interface(), err
+		}
+	}
+
+	if typ.Kind() == reflect.Map {
+		return func(vs []any) (any, error) {
+			return mergeMap(typ, vs)
+		}
+	}
+
+	return nil
+}
+
+func mergeMap(typ reflect.Type, vs []any) (any, error) {
+	merged := reflect.MakeMap(typ)
+	for _, v := range vs {
+		if t := reflect.TypeOf(v); t != typ {
+			return nil, fmt.Errorf(
+				"(values merge map) field type mismatch. expected: '%v', got: '%v'", typ, t)
+		}
+
+		iter := reflect.ValueOf(v).MapRange()
+		for iter.Next() {
+			key, val := iter.Key(), iter.Value()
+			if merged.MapIndex(key).IsValid() {
+				return nil, fmt.Errorf("(values merge map) duplicated key ('%v') found", key.Interface())
+			}
+			merged.SetMapIndex(key, val)
+		}
+	}
+
+	return merged.Interface(), nil
+}


Reviews:
user: login: meguminnnnnnnnn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T07:27:31Z
user: login: wolfogre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T10:52:24Z
user: login: wolfogre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T10:53:13Z
user: login: meguminnnnnnnnn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T12:46:45Z
user: login: wolfogre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T02:42:09Z
user: login: meguminnnnnnnnn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T10:01:13Z
user: login: meguminnnnnnnnn
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T10:01:24Z

Review comments:
diff_hunk: @@ -0,0 +1,72 @@
+/*
path: compose/fanin_merge.go
user: login: meguminnnnnnnnn
body: 命名叫values merge吧， fan in在eino其他地方没有提到过，感觉有点突兀
created_at: 2025-03-28T04:12:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,72 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package compose
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/cloudwego/eino/internal"
+)
+
+// RegisterFanInMergeFunc registers a function to merge outputs from multiple nodes when fan-in.
+// It's used to define how to merge for a specific type.
+// For maps that can be merged naturally, you don't need to register a function unless you want to customize the merge logic.
+// Please note that it's completely different from RegisterStreamChunkConcatFunc which is used to concat stream chunks.
path: compose/fanin_merge.go
user: login: meguminnnnnnnnn
body: For maps...  merge是我们定义的，所以没有「naturally」的概念，这里说明map已经预注册过或或者提供了默认的merge方法，不需要额外注册

Please note...  这个没必要说，有明确需求的情况下应该不会搞混
created_at: 2025-03-28T04:13:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,72 @@
+/*
path: compose/fanin_merge.go
user: login: wolfogre
body: ed97b72
created_at: 2025-03-28T10:52:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,72 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package compose
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/cloudwego/eino/internal"
+)
+
+// RegisterFanInMergeFunc registers a function to merge outputs from multiple nodes when fan-in.
+// It's used to define how to merge for a specific type.
+// For maps that can be merged naturally, you don't need to register a function unless you want to customize the merge logic.
+// Please note that it's completely different from RegisterStreamChunkConcatFunc which is used to concat stream chunks.
path: compose/fanin_merge.go
user: login: wolfogre
body: 2a979e4
created_at: 2025-03-28T10:53:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package compose
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/cloudwego/eino/internal"
+)
+
+// RegisterValuesMergeFunc registers a function to merge outputs from multiple nodes when fan-in.
path: compose/values_merge.go
user: login: meguminnnnnnnnn
body: it‘s  「usually used in multiple outputs fan-in scenarios」, not only to use
created_at: 2025-04-16T12:45:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package compose
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/cloudwego/eino/internal"
+)
+
+// RegisterValuesMergeFunc registers a function to merge outputs from multiple nodes when fan-in.
path: compose/values_merge.go
user: login: wolfogre
body: So, could you please clarify it? "to merge outputs from multiple nodes when fan-in" and what?

I have checked the three usages of ⁠mergeValues, but all of them seem to be merging outputs.
created_at: 2025-04-21T02:42:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package compose
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/cloudwego/eino/internal"
+)
+
+// RegisterValuesMergeFunc registers a function to merge outputs from multiple nodes when fan-in.
path: compose/values_merge.go
user: login: meguminnnnnnnnn
body: Yes, it's only used in fan-in scenario. My original idea was not to restrict MergeValues to be only used in fan-in, so that if there are other places that need to use merge in the future, they can be used directly. However, considering that the added usage originally requires user awareness, it can also wait until the expansion of MergeValues usage scenarios.
created_at: 2025-04-21T10:01:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: wolfogre
created_at: 2025-04-02T11:12:17Z
body: The failed CICD doesn't seem related. It failed when "Send Coverage Report" was executed. Please help.
author_association: CONTRIBUTOR
user: login: wolfogre
created_at: 2025-04-07T08:43:01Z
body: > The failed CICD doesn't seem related. It failed when "Send Coverage Report" was executed. Please help.

@meguminnnnnnnnn Could you please take a look at this?
author_association: CONTRIBUTOR
user: login: meguminnnnnnnnn
created_at: 2025-04-08T07:03:33Z
body:  It's because the bot cannot comment on this PR, no need to worry about it. 

I have been quite busy recently, do you need this PR urgently? If not, I may come back to review it this weekend or early next week.
author_association: CONTRIBUTOR
user: login: wolfogre
created_at: 2025-04-09T02:42:38Z
body: It's fine, please take your time.
author_association: CONTRIBUTOR